Custom WordPress Editor Blocks

October 25, 2020

This is a possible setup for a WordPress plugin that adds a new custom editor block. Check out the full repo on Github. The plugin is based on the @wordpress/scripts package which automatically builds the necessary scripts, creates the dependency array and allows for easy enqueueing in WordPress. The file structure is supposed to be used as a starter and contains templates for standard blocks as well as dynamic (server side rendered) ones.

The PHP side of things is based on an object oriented approach and makes use of composer. The plugin should thus be installed using composer if any classes are called (or you will manually have to create/copy the autoloader files).

Basic PHP Setup

As per WordPress expectations and default the main plugin logic and entry point can be found in rh-editor-block-starter.php. We begin by declaring the namespace and then add the usual plugin doc block with all meta information WordPress needs. Make sure to add the plugin title as a translated string directly after the block comment to display the title to each user in their preferred language.

<?php
namespace RHEditorBlockStarter;

/**
 * Plugin Name: RH Editor Block Starter
 * ... [add more plugin meta information] ...
 */
__('RH Editor Block Starter', 'rh-editor-block-starter');

// Exit if accessed directly.
if (!defined('ABSPATH')) {
  exit;
}

// Check for 'local' autoloader and include it if there is one.
// This is a fallback for cases where the plugin was not installed using composer.
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
  require __DIR__ . '/vendor/autoload.php';
}

// Initialize the plugin
Plugin::init(__FILE__);

Check out the full example file on Github. We initialize the Plugin class with the __FILE__ magic constant, since this is used in several places throughout the plugin. In Plugin we make this available in a private property.

All other plugin logic is placed within the src folder. Since the plugin is based on psr-4 make sure to name folders and files appropriately. Our plugin initializer lives in src/Plugin.php.

<?php
namespace RHEditorBlockStarter;

class Plugin
{
  /**
   * The location (__FILE__ constant of the main plugin file) of the plugin.
   *
   * @var string
   */
  private $pluginFile;

  /**
   * Constructor.
   *
   * @param string $file
   */
  public function __construct($file)
  {
      $this->pluginFile = $file;
  }

  /**
   * Static class initializer that hooks into WordPress
   *
   * @param string $file
   */
  public static function init($file)
  {
      $self = new self($file);

      add_action('plugins_loaded', [$self, 'loadTextDomain']);
      add_action('wp_loaded', [$self, 'initBlock']);

      /** Initialize a custom post type */
      // CustomPostType::init();
      /** Uncomment if you use server-side render */
      // EditorBlock\Render::init();
  }
}

We make use of a static initializer that sets up the plugin and hooks into WordPress. The init function expects the magic constant __FILE__ as its argument which is then passed to the class constructor which stores it in a private property.

The initializer is also where you can initialize a custom post type or a server side rendered block.

Translation Setup

To make translated strings available where WordPress i18n functions were used we need to load the text domain. The method we need is already hooked to the plugins_loaded action and called loadTextDomain.

<?php
namespace RHEditorBlockStarter;

class Plugin
{
  // ...

  /**
   * Load plugin text domain
   */
  public function loadTextDomain()
  {
    load_plugin_textdomain(
      'rh-editor-block-starter',
      false,
      dirname(plugin_basename($this->pluginFile)) . '/languages'
    );
  }
}

This is just the basic WordPress load_plugin_textdomain() function, where we add the text domain as first argument, a deprecated second argument and the path to the folder where the .mo file is stored. There we make use of the stored __FILE__ constant.

Block Initializer

<?php
namespace RHEditorBlockStarter;

class Plugin
{
  // ...

  /**
   * Block initializer: handles registering the script, styles and block type, as well as script translations.
   */
  public function initBlock()
  {
    // Load the asset file generated by `@wordpress/scripts` build
    $assetFilePath = plugin_dir_path($this->pluginFile) . 'build/index.asset.php';

    // Bail if there is no asset file
    if (!file_exists($assetFilePath)) {
      return false;
    }

    $assetFile = include($assetFilePath);

    wp_register_script(
      'rh-editor-block-starter-editor-js',
      plugins_url('build/index.js', $this->pluginFile),
      $assetFile['dependencies'],
      $assetFile['version'],
    );

    wp_register_style(
      'rh-editor-block-starter-editor-css',
      plugins_url('assets/styles/editor.css', $this->pluginFile),
      ['wp-edit-blocks'],
      filemtime(
        plugin_dir_path($this->pluginFile) . 'assets/styles/editor.css'
      )
    );

    // Pretty much the same logic for fronted styles, just with a different or an empty dependency array.

    // Delete the following if you have a server side rendered block. If you add several blocks using the plugin, add them here as well.
    register_block_type('rafhun/rh-editor-block-starter', [
      'editor-script' => 'rh-editor-block-starter-editor-js',
      'editor_style' => 'rh-editor-block-starter-editor-css',
    ]);

    // Set up the JS translation functions
    wp_set_script_translations(
      'rh-editor-block-starter-editor-js', // Script handle
      'rh-editor-block-starter', // Text domain
      plugin_dir_path($this->pluginFile) . 'languages' // Path to translation files
    );
  }
}

There is a lot going on here, everything to do with setting up a custom block. First we check for the asset file generated by @wordpress/scripts which returns an array with different information about the generated script file. We make use of this information to register the block script (wp_register_script) and styles (wp_register_style) which then are used to register the block itself (register_block_type). Finally we use wp_set_script_translations to set up translations for strings in scripts that make use of WordPress i18n functions (such as __()).

You can find the full file in its repo.

Basic JS Setup

Our Javascript folder structure and code organization follows the example of the WordPress core blocks. The @wordpress/scripts package looks for an index file in the src folder and compiles scripts into the build folder. In our entry file we handle importing and registering our custom blocks. There is an option that allows us to easily remove core blocks (in case our custom block replaces it) which is also handled.

Everything gets split up into its own partials and imported within the index.js file of the block, where we export the data that is given to the registerBlockType function. By default the following files are present for blocks and can be used to configure the block. Again this file structure follows the example given in the core blocks.

block.json

This is where all block metadata is stored. The information in this file is used for block registration in both server side and client side registration. From observing Gutenberg development it is ovious that this file will gain in importance and more and more information will be stored within it. At the moment (for core v5.5) it contains the block name, category, attributes and supports.

deprecated.js

Add to this file once you update a block and old API’s become deprecated. With the help of what is available in this file blocks can then automatically be updated to their new versions. This includes, where necessary migrating attributes or updating markup (i. e. for a new save function).

edit.js

Sets up the edit function which needs to be a Gutenberg React component. This function defines how our block will behave in the editor.

icon.js

Use this file to add your own custom icon, if one is not available in the @wordpress/icons package.

index.js

Imports all metadata and settings (basically all files described here), puts them together correctly and exports everything for client side registration.

This file also exports a constant removesBlock which can be used to set up core blocks for removal.

save.js

Only available in standard blocks, since server side rendered blocks do not need a save function. The save function defines, how the attributes are compiled into HTML output. It is also a function or Gutenberg React component.

transforms.js

Use this file to define how the block can be transformed into another block (imagine transforming a paragraph to a heading block) and vice versa, so how different blocks can be transformed into this one.

Render.php

Only available for server side rendered / dynamic blocks. In this file we register the block server side and define the render_callback function. This function defines the markup that is computed and output on each block render.

Build

The main build process is provided by the @wordpress/scripts package which gives access to a few differing build commands. You can find all of the commands listed in package.json, the most important ones are listed below. Use npm run %command% or yarn %command% (replace %command% with the one given below, i. e. npm run build).

build

Generates a production ready build of your scripts.

start

Generates a development build and starts a watch task. The local port on which changes are hot reloaded will be indicated in your console.

makepot

This script requires WP CLI to be available in the PATH as it is jsut an alias for a CLI command that generates a pot file for all translatable strings in PHP and Javascript files.

makejson

Again this is an alias for a WP CLI command that looks for po files in the languages folder and generates JSON translation files which contain the translatable strings extracted from JS files. By default we do not purge the po file, so we have manually translated strings available at a later point.

release

Run this once everything is committed and ready for a release. The command first does a git push to make sure the online repo is up to date, then generates a build and finally runs the tag-dist-files script which reads in the targeted version from package.json and adds the build folder which otherwise is gitignored to the release. If you want to ensure full compatibility even if the plugin was not installed through composer you can also add the vendor/ folder to the release. Configure files that are added to each release in the files array in package.json.


Written by Raphael Hüni Dev based in Switzerland focusing mostly on frontend technology.