Possible to use @wordpress/create-block with multiple blocks?

It is! I’ve toyed with this off and on for the last year or so, and have come up with a few different ways to accomplish it. These are just the products of my own fiddling, however – there may well be more compelling solutions out there. Given the direction of @wordpress/scripts development, I would expect this use-case to become easier down the road.

NOTE: If you intend to submit your blocks for inclusion in the public Block Directory, (introduction) current guidelines specify that the plugin should provide only a single top-level block. Any additional blocks should be child blocks and specify their relationship with the top-level block via the parent field in their respective block.json files.


Background: The @wordpress/scripts Package

@wordpress/scripts is the abstraction of build and development tools which is used in plugins scaffolded by @wordpress/create-block in order to simplify JS transpilation, SCSS compilation, and linting code for errors, among other things. It includes babel, webpack, and eslint, to name a few.

The package is also useful as a development dependency in plugins and themes which do not offer blocks; the same configuration process below can be used to better adapt the build tools to your extension’s needs and file structure.

The Default Build Configuration

The most common mechanism to change any aspect of how your plugin’s assets are built is to tweak or replace the Webpack configuration provided by the @wordpress/scripts package, as is briefly mentioned in the README.

To get an idea of what the structure and settings of the default configuration object are we can refer to the source of wp-script’s webpack.config.js file. The main settings that we are concerned with are the entry and output objects, which determine which files serve as entry points to your code and where their assets are compiled to, respectively:

// ...
    entry: {
        index: path.resolve( process.cwd(), 'src', 'index.js' ),
    },
    output: {
        filename: '[name].js',
        path: path.resolve( process.cwd(), 'build' ),
        jsonpFunction: getJsonpFunctionIdentifier(),
    },
// ...

Above, we can see that wp-scripts specifies a single entry-point named index located at ./src/index.js, and produces a JavaScript bundle for each entry-point at ./build/[name].js (where here, index would be substituted in for [name] for our single entry-point).

A number of other assets are produced by the various configured plugins and loaders as well.

Overriding the Webpack Configuration

Overriding the default webpack.config.js is simple – we just create a file of the same name in our project root, and wp-scripts will recognize and use it instead.

To modify the configuration, we can either import wp-scripts’ Webpack configuration object, modify it, and export the modified object again – or export a new configuration object entirely to completely replace wp-scripts’.

NOTE: It is a popular convention to use the CommonJS module pattern and a less recent ECMAScript syntax when writing a Webpack configuration file as this file is not usually transpiled into a more global standard. This allows developers using less recent Node.js engines to build your code as well.


Solutions

File Structure

The majority of the following solutions assume a source structure containing two blocks (foo and bar) in a ./src/blocks directory alongside a non-block JS asset (./src/frontend/accordion.js), unless otherwise stated. Specifically, I’ve configured them for a project structure of my own preference:

@bosco's multiblock project structure

Copying Assets from Source Directories

In my solutions below I use the CopyWebpackPlugin in order to copy each block’s block.json file to the output directory. This allows them to be used straight from the output directory using relative paths to assets which make sense in either context. As @wordpress/scripts is currently using Webpack 4 (though not for much longer), you will need version 6 of the plugin, for the time being:

npm install --save-dev copy-webpack-plugin@6

Alternatively, you can load your block.json files from ./src and use large relative paths to point at the built assets (e.g. "editorScript": "file:../../../build/blocks/foo/index.js"), or perhaps collect them in your project root, named as block-{block name}.json or similar.

Loading Blocks from the New Structure

For the most part, you can simply pass the filepath to each block’s block.json file to a register_block_type_from_metadata() call in your root project’s main plugin file.

PHP relevant to specific blocks including block registration could also be left in the block’s source directory, and either imported straight from there or copied over to the output directory.

Multiple Block Projects Solution

The most simple solution results in a somewhat convoluted file structure and build process – to just use @wordpress/create-block to scaffold multiple block projects into your root project. After which, they can be registered by simply loading each block’s plugin entry point or migrating all of the register_block_type()/register_block_type_from_metadata() calls into your project’s main PHP entry point.

This arrangement directly lends well to monorepo practices such as those detailed in the link in @Luismi’s answer as well as this LogRocket article.

Such an approach could also be combined with one of the solutions below in order to consolidate the individual block projects with a shared build process and output directory. This seems pretty compelling on paper, but I have not explored the possibility.

Multi-Config Solution (Most Reasonable ATM)

Webpack supports configuration files exporting an array of configuration objects, which allows you to provide an individual configuration to use for each block.

Unfortunately, since Node.js caches and re-uses module imports and since the default @wordpress/scripts configuration object contains various constructed objects and functions, using even a recursive copy of the object for each block has a potential for causing problems, as multiple Webpack compilations could end up re-using plugin instances which may have a dirty state from prior compilations.

I think that the best way to implement this may be to create a sort of “configuration factory function” which can be used to produce a new configuration object – basically copying and pasting the default configuration into a function.

As a sort of hacky alternative, deleting the default configuration from the Node.js module cache results in a brand new copy of the object each time it’s require()‘d. I’m not sure how good of a practice this is, however. I would trust the prior method to be more reliable and generally acceptable. Nonetheless, this does make things quite a bit easier:

/**
 * `@wordpress/scripts` multi-config multi-block Webpack configuration.
 * @see https://wordpress.stackexchange.com/questions/390282
 */

// Native Depedencies.
const path = require( 'path' );

// Third-Party Dependencies.
const CopyPlugin = require( 'copy-webpack-plugin' );

const default_config_path = require.resolve( '@wordpress/scripts/config/webpack.config.js' );

/**
 * Retrieves a new instance of `@wordpress/scripts`' default webpack configuration object.
 * @returns WebpackOptions
 */
const getBaseConfig = () => {
  // If the default config's already been imported, clear the module from the cache so that Node
  // will interpret the module file again and provide a brand new object.
  if( require.cache[ default_config_path ] )
    delete require.cache[ default_config_path ];

  // Import a new instance of the default configuration object.
  return require( default_config_path );
};

/**
 * @callback buildConfig~callback
 * @param {WebpackOptions} config An instance of `@wordpress/scripts`' default configuration object.
 * @returns WebpackOptions The modified or replaced configuration object.
 */

/**
 * Returns the result of executing a callback function provided with a new default configuration
 * instance.
 *
 * @param {buildConfig~callback} callback
 * @returns WebpackOptions The modified or replaced configuration object.
 */
const buildConfig = ( callback ) => callback( getBaseConfig() );

/**
 * Extends `@wordpress/scripts`'s default webpack config to build block sources from a common
 * `./src/blocks` directory and output built assets to a common `./build/blocks` directory.
 * 
 * @param {string} block_name 
 * @returns WebpackOptions A configuration object for this block.
 */
const buildBlockConfig = ( block_name ) => buildConfig(
  config => (
    { // Copy all properties from the base config into the new config, then override some.
      ...config,
      // Override the block's "index" entry point to be `./src/blocks/{block name}/index.js`.
      entry: {
        index: path.resolve( process.cwd(), 'src', 'blocks', block_name, 'index.js' ),
      },
      // This block's built assets should be output to `./build/blocks/{block name}/`.
      output: {
        ...config.output,
        path: path.resolve( config.output.path, 'blocks', block_name ),
      },
      // Add a CopyWebpackPlugin to copy over the `block.json` file.
      plugins: [
        ...config.plugins,
        new CopyPlugin(
          {
            patterns: [
              { from: `src/blocks/${block_name}/block.json` },
            ],
          }
        ),
      ]
    }
  )
);

module.exports = [
  buildBlockConfig( 'foo' ),
  buildBlockConfig( 'bar' ),
  // Setup a configuration to build `./src/frontend/accordion.js` to `./build/frontend/`
  buildConfig(
    config => (
      {
        ...config,
        entry: {
          accordion: path.resolve( process.cwd(), 'src', 'frontend', 'accordion.js' ),
        },
        output: {
          ...config.output,
          path: path.resolve( config.output.path, 'frontend' ),
        },
      }
    )
  )
];

Path-Based Entry Names Solution

This solution depends on a change to @wordpress/scripts which will not be available until the next release, (package version > 16.1.3). To use it now, you would need to install the package from GitHub.
wp-scripts’ impending upgrade to Webpack 5 should also facilitate this approach.

The most convenient solution in my opinion is simply to use partial paths as entry-point names:

/**
 * `@wordpress/scripts` path-based name multi-block Webpack configuration.
 * @see https://wordpress.stackexchange.com/questions/390282
 */

// Native Depedencies.
const path = require( 'path' );

// Third-Party Dependencies.
const CopyPlugin = require( 'copy-webpack-plugin' );
const config = require( '@wordpress/scripts/config/webpack.config.js' );

/**
 * Resolve a series of path parts relative to `./src`.
 * @param string[] path_parts An array of path parts.
 * @returns string A normalized path, relative to `./src`.
 **/
const resolveSource = ( ...path_parts ) => path.resolve( process.cwd(), 'src', ...path_parts );

/**
 * Resolve a block name to the path to it's main `index.js` entry-point.
 * @param string name The name of the block.
 * @returns string A normalized path to the block's entry-point file.
 **/
const resolveBlockEntry = ( name ) => resolveSource( 'blocks', name, 'index.js' );

config.entry = {
  'blocks/foo/index': resolveBlockEntry( 'foo' ),
  'blocks/bar/index': resolveBlockEntry( 'bar' ),
  'frontend/accordion': resolveSource( 'frontend', 'accordion.js' ),
};

// Add a CopyPlugin to copy over block.json files.
config.plugins.push(
  new CopyPlugin(
    {
      patterns: [
        {
          context: 'src',
          from: `blocks/*/block.json`
        },
      ],
    }
  )
);

module.exports = config;

This is something of a convenience in that it’s simple, succinct, and maintainable… with a caveat.

Due to the way that @wordpress/scripts handles CSS/SCSS files name “style” and “style.module” in order to work around a Webpack 4 limitation, we need to modify how these files are named in order to make sure that the “style” assets end up in the same directory as the rest of the built assets. It’s ugly, and I haven’t thoroughly tested possible edge cases (in particular it might do some weird stuff if a “style” file produces multiple chunks) – but with any luck it won’t be necessary in Webpack 5:

config.optimization.splitChunks.cacheGroups.style.name = ( module, chunks, group_key ) => {
  const delimeter = config.optimization.splitChunks.cacheGroups.style.automaticNameDelimiter;

  return chunks[0].name.replace(
    /(\/?)([^/]+?)$/,
    `$1${group_key}${delimeter}$2`
  );
};

Flat Output Solution

A very minimal configuration can facilitate compiling assets to a super ugly flat-file output structure:

/**
 * `@wordpress/scripts` flat output multi-block Webpack configuration.
 * @see https://wordpress.stackexchange.com/questions/390282
 */

// Native Depedencies.
const path = require( 'path' );

// Third-Party Dependencies.
const CopyPlugin = require( 'copy-webpack-plugin' );
const config = require( '@wordpress/scripts/config/webpack.config.js' );

config.entry = {
  'foo-block': path.resolve( process.cwd(), 'src', 'blocks', 'foo', 'index.js' ),
  'bar-block': path.resolve( process.cwd(), 'src', 'blocks', 'bar', 'index.js' ),
  'accordion': path.resolve( process.cwd(), 'src', 'frontend', 'accordion.js' ),
};

config.plugins.push(
  new CopyPlugin(
    {
      patterns: [
        {
          context: 'src/blocks',
          from: '*/block.json',
          to: ( { absoluteFilename } ) => `block-${absoluteFilename.match( /[\\/]([^\\/]+)[\\/]block.json/ )[1]}.json`,
        },
      ],
    }
  )
);

module.exports = config;

flat output

On paper one could set a function in config.output.filename in order to have this simple configuration produce a nested output structure again (replacing - in the entry-point name with /, or similar), but this too is not currently possible as a result of the FixWebpackStylePlugin‘s implementation.

Leave a Comment