How to automatically create Gutenberg blocks from php classes

An abstract block strategy
Posted in Gutenberg, the 07/01/2021.

Introduction

At Wonderful, Most of the components we use to render elements on a page are php based. We’ve accumulated and consolidated them over the years, and they’re now coupled with many of our administration plugins.

Before the advent of Gutenberg, those components were most of the time hydrated by shortcode values. Shortcodes are mastered by our developers, which means that for them they are easy to declare, quick to implement and to get the administered data out of them to pass those values to the php component for rendering. On the downside, they can be limited for advanced administration needs.

Then Gutenberg came along, with a brand new approach : blocks. Blocks offer a much better editing experience than shortcodes, especially when working with media or complex data structures, (or ones that repeat). But this power comes with a cost : it’s more complex to declare and implement than anything else (including shortcodes). Blocks are also written in JavaScript, preferably in React to be more precise, and this particularity rules out some of our developers from developing blocks at the time, as I explained in detail in this article about how we made the team adopt Gutenberg.

The need for a quick, inclusive and easy option

Like I said in the introduction, we mostly use php components to render our data. The creation of those components is well documented and mastered by the whole team.

Those are the reasons that led us to the conclusion that we needed to retrieve a solution that was quick and easy to implement for everyone in the team to address simple use cases. A solution that would be quick to develop but that would still make the editing experience of our users better by using Gutenberg instead of using shortcodes.

If the solution to a user need is more complex than this, we would build him a full featured Gutenberg block instead.

The plan (also TL;DR)

The idea of our solution is is follows :

  • We’ll add annotations to a component class and its attributes
  • Adding those annotations would automatically make this php based component available as a Gutenberg block in the editor.
  • It would take the form of an admin form to fill, with one field per attribute.
  • Then the administered data would be passed back to the component which will render it.

Essentially we’d like to turn this kind of component :

class CodeComponent extends AbstractComponent
{
    /**
     * @var string
     */
    protected $language;

    /**
     * @var string
     */
    protected $snippet;

//[...]
}

Into this kind of administration form automatically in Gutenberg :

Code snippet admin form screenshot

By adding annotations like so :

/**
 * @Block(title="Code Snippet")
 */
class CodeComponent extends AbstractComponent
{
    /**
     * @var string
     * @BlockAttributes(component="PlainText",type="string",componentAttributes={"placeholder":"Language"})
     */
    protected $language;

    /**
     * @var string
     * @BlockAttributes(component="PlainText",type="string",componentAttributes={"placeholder":"Code"})
     */
    protected $snippet;

//[...]
}

As you can see : we’re settling for a middle line solution. A form to fill is a step below a custom Gutenberg block experience for the end user but still better than shortcodes, and for the developers : it’s quicker to create than original Gutenberg blocks, and it’s still in a language they master. This solution will be chosen when appropriate based on the block end function.

The implementation

Step 1 : A proper php annotations strategy

Originally for us, a php component is a class that has attributes, and a method that renders markup (either directly, or via an associated twig template).

We can take advantage of this existing structure to add annotations at the class level, and at the attributes level.

Here’s an example of the beginning of a an existing component class :

class AddressComponent extends AbstractComponent
{
    /**
     * @var string
     */
    protected $title;

    /**
     * @var string
     */
    protected $address;

//[...]

}

On this code snippet, we will then apply two types of annotations : the class based one and the attributes based one.

Class based annotations

Class based annotations will pilot the block declaration, here’s how they are declared :

/**
 * @Annotation
 * @Target({"CLASS"})
 */
final class Block
{
    /** @var string */
    public $title;
    /** @var string */
    public $icon;
    /** @var string */
    public $category;
}

It allows us to pilot three things :

  • title : the name of the block within the Gutenberg editor. If not set, the class name is used as block title.
  • category : By default the block will be stored under a specific category called molecules, that we created to store those particular blocks. But it can be changed to any existing category.
  • icon : you can specify the name of an icon. By default it will be the abstract block icon.

Attributes based annotations

The attributes based annotations are a way to pilot each attribute behavior and are declared like this :

/**
 * @Annotation
 * @Target({"PROPERTY"})
 */
final class BlockAttributes
{
    /** @var string */
    public $component;
    /** @var string */
    public $type;
    /** @var string */
    public $label;
    /** @var array */
    public $componentAttributes;
}

This case is a bit more complex, we have more things we can parameter for each block attributes, which will become a field to fill in a form on the abstract Gutenberg block. Here’s the detail for each possibility :

  • Component : This is the react component name to use to render to form field. Most popular ones include PlainText, RichText, MediaUpload or InnerBlocks for example.
  • Type : This is the data type for this attribute. You can see the list of accepted types on the official Gutenberg attributes documentation
  • Label : You can specify the form field label. If not set, the attribute name will be used as default.
  • ComponentAttributes: can be an array of options we’ll use to pilot the field behavior when needed.

Let’s now see how we use those annotation capabilities on our example component :

/**
 * @Block(title="Adresse")
 */
class AddressComponent extends AbstractComponent
{
    /**
     * @var string
     * @BlockAttributes(component="PlainText",type="string",componentAttributes={"placeholder":"Titre"})
     */
    protected $title;

    /**
     * @var string
     * @BlockAttributes(component="RichText",type="string",componentAttributes={"placeholder":"Adresse postale"})
     */
    protected $address;

Thanks to the class based annotation, we’ll make the component available in Gutenberg inside the “molecules” category, with the name “Adresse“, and with the default abstract block icon.

Then we specify that the address title will be a string editable via PlainTextComponent.

And finally the address part itself will be a RichText component.

Step 2 : Gathering annotations knowledge and pass it to JavaScript

In Gutenberg, block declaration is a mix of php and JavaScript code. In our scenario, we would like to achieve two things :

  • Automate the php part as much as possible. The only thing we are willing to do is annotate our php components.
  • Completely abstract the JavaScript part so we won’t have to touch it when we would create annotated blocks.

All the rest of the block declaration and logic should work without further action from our developers.

Automating the php part thanks to annotations

We have created a class called the molecule registrator, which role is to take a list of annotated classes (we call them molecules) , and create the corresponding blocks for each of them. The corresponding code looks like this :

    public function createBlocks()
    {
        if (!empty($this->molecules)) {
            foreach ($this->molecules as $moleculeClass) {
                $molecule = new $moleculeClass();
                $block    = $this->createBlock($molecule);
                if (!empty($block)) {
                    $this->addBlock($block);
                }
            }
        }
    }

As you can see we loop on each declared molecule class, and create the block for it thanks to the createBlock method.

Here’s the detail of this createBlock method :

    /**
     * @param AbstractComponent $molecule
     *
     * @return AbstractBlock
     */
    protected function createBlock(AbstractComponent $molecule)
    {
        $abstractBlock = new MoleculeBlock();
        try {
            //Point A : Gather the annotations
            $reflectionClass = new \ReflectionClass($molecule);
            $annotations     = $abstractBlock->extractAnnotationsFromMolecule($reflectionClass, $molecule);
            $shortClassName  = $reflectionClass->getShortName();
        } catch (\Exception $e) {
            $annotations    = [];
            $className      = get_class($molecule);
            $frags          = explode('\\', $className);
            $shortClassName = end($frags);
        }
        if (empty($annotations)) {
            return null;
        }
        $attributes = $annotations['properties'];
        //Point B : Automate the block declaration
        $props      = [
           //Point C : every molecule will share the same JavaScript class : the one able to create abstract blocks
            'editor_script'   => 'wwp-abstract-block',
            'editor_style'    => 'wwp-molecule-block-editor',
            'attributes'      => $attributes,
            //Point D : The render callback is driven back to the molecule
            'render_callback' => [$abstractBlock, 'render'],
        ];
        $blockName  = 'wwp-gutenberg-utils/molecule-' . strtolower($shortClassName) . '-block';
        $abstractBlock->setBlock(new \WP_Block_Type($blockName, $props));
        $abstractBlock->setAnnotations($annotations);

        return $abstractBlock;
    }

Here’s nearly the whole logic in 2 steps : get the annotations near point A, then use them to automate the block creation near point B.

There are two other special things that are notable here :

  • Point C : every molecule will share the same JavaScript class, the one able to create abstract blocks.
  • Point D : The render callback is driven back to the molecule, which means that it will be the annotated class responsibility to render the final markup (which is specifically what we want in our case).

Passing knowledge to the JavaScript

For this, we rely on another method of our molecule registrator service called addBlocksToJsConfig which implementation follows :

/* Point A : in our architecture, $jsConfig is an array driven by php and made available to our JavaScript. 
So what we need to do is add our block knowledge to $jsConfig to be able to locate it in our JS. */
 
public function addBlocksToJsConfig(array $jsConfig)
    {
        if (empty($jsConfig['blocks'])) {
            $jsConfig['blocks'] = [];
        }

        if (!empty($this->blocks)) {
            foreach ($this->blocks as $abstractBlock) {
                $block      = $abstractBlock->getBlock();
                $title      = $this->getInAnnotations($abstractBlock, 'title', __(str_replace('gutenberg-utils-molecule-block/', '', $block->name), WWP_GUTENBERGUTILS_TEXTDOMAIN));
                $cat        = $this->getInAnnotations($abstractBlock, 'category', self::BLOC_CAT);
                $icon       = $this->getInAnnotations($abstractBlock, 'icon', 'archive');
                $attributes = $block->attributes;
                if (!empty($abstractBlock->getAnnotations()['options'])) {
                    $attributes = array_merge($attributes,$abstractBlock->getAnnotations()['options']);
                }
                $jsConfig['blocks'][$block->name] = [
                    'name'       => $block->name,
                    'title'      => $title,
                    'category'   => $cat,
                    'attributes' => $attributes,
                    'icon'       => $icon,
                ];
            }
        }

        return $jsConfig;
    }

As you can see from the code above : we loop on each molecule to build an array of information that we pass to our JS.

Here’s the kind of data structure our addBlocksToJsConfig method produces and passes to the JS:

Screenshot of the data passed to the JavaScript by the addBlocksToJsConfig method (attributes, category, icon, name)

We will now see how our JS uses this array to build abstract blocks automatically on the client side.

Step 3 : Abstracting the block creation on the JavaScript side

On the client side, the aim is to scan and use the blocks definition data passed to the JS to build a corresponding administration form automatically.

We start with the edit method of our block, in which we’ll build and render our administration form. We’ll build this form by looping on each attribute and use its data to create the corresponding form field.

Let’s start by looking at the edit method :

  edit(props) {
    const {className} = props;

//This is where we build the edit form
    const {optionsForm, attributesForm} = this.renderAttributesForm(props);
    const inspectorControls = this.getInspectorControls(optionsForm);

    return (
      {inspectorControls}
      
{this.opts.title} //This is where we render it {attributesForm.map((attributeField) => { return attributeField; })}
); }

This is the detail of the renderAttributesForm method :

 renderAttributesForm(props) {
    const {attributes} = props;

    let attributesForm = [];

    Object.keys(this.opts.attributes).map((key) => {
      if (key !== 'className' && key !== 'layout') {
        let attr = this.opts.attributes[key];

          switch (attr.component) {
            case 'MediaUpload':
              attributesForm.push(this.mediaUploadSubComponent(key, attributes, props));
              break;
            default:
              attributesForm.push(this.defaultSubComponent(key, attributes, props));
              break;
          }
        
        
      }
    })

    return {optionsForm, attributesForm};
  }

Essentially we loop through each attribute and decide which sub component to render for each one. We make a particular case for the media upload component, otherwise we go into the defaultSubComponent method.

Here’s the detail of the mediaUploadSubComponent method :

  mediaUploadSubComponent(key, attributes, props) {
    let attr = this.opts.attributes[key];
    attr.componentAttributes = attr.componentAttributes || {};

    const Component = wp.blockEditor[attr.component];
    return (<Component
      key={key}
      onSelect={this.onMediaSelect.bind(this, key, props)}
      value={attributes[key]}
      render={this.mediaRender.bind(this, key, attributes, props)}
      {...attr.componentAttributes}
    />);
  }

And here’s the detail of the defaultSubComponent one :

  defaultSubComponent(key, attributes, props) {
    let attr = this.opts.attributes[key],
      label = key,
      componentName = attr.component;

    attr.componentAttributes = attr.componentAttributes || {};

    let wrapClasses = componentName + "-wrap " + key + "-wrap";

    let val = attributes[key];
    if (!val && attr.componentAttributes && attr.componentAttributes['data-default-value']) {
      val = attr.componentAttributes['data-default-value'];
    }

    if (componentName === 'HiddenText') {
      componentName = 'PlainText';
      wrapClasses += " hidden";
      this.onChange(key, props, val);
    }

    const Component = wp.blockEditor[componentName];

    return (<div key={key} className={wrapClasses}>
      <label>{label} : </label>
      <Component
        onChange={this.onChange.bind(this, key, props)}
        value={val}
        {...attr.componentAttributes}
      /></div>);
  }

This class is the same for every molecule we create, it doesn’t change when creating a new one. It’s smart enough to adapt to every molecule structure given the fact the molecule is properly annotated. We can focus on the most essential part of the design of a solution for our user without friction. We can also improve this class over time to make it more intelligent.

For example, recently we added the capability to generate options in the Gutenberg sidebar when you select a block, still via our annotation system.

Step 4 : the server side rendering

So far we have on one hand an annotated php class, and on the other one a back office administration form we can fill. We now need to get the administered data and pass it back to our original class for rendering.

If you remember the code snippet of the createBlock method earlier, we had this line in it :

//Point D : The render callback is driven back to the molecule
            'render_callback' => [$abstractBlock, 'render'],

Here’s the detail of this render method :

    public function render(array $attributes = [], $content = null)
    {
        if (empty($attributes['calledClass'])) {
            return (new NotificationComponent('error', 'No molecule class provided'))->getMarkup();
        }

        $moleculeName = $attributes['calledClass'];
        if (!class_exists($moleculeName)) {
            return (new NotificationComponent('error', 'Molecule class provided ' . $moleculeName . ' does not exist.'))->getMarkup();
        }

        try {
//Point A : instanciate the component
            /** @var AbstractComponent $molecule */
            $molecule        = new $moleculeName();
            $reflectionClass = new ReflectionClass($molecule);
//Point B :extract annotations to better know how to use the passed data with this component
            $annotations     = $this->extractAnnotationsFromMolecule($reflectionClass, $molecule);
        } catch (Exception $e) {
            return (new NotificationComponent('error', 'Could not parse annotations from provided molecule : ' . $moleculeName . '.'))->getMarkup();
        }

        $moleculeAttributes = !empty($annotations['properties']) ? $annotations['properties'] : [];
        if (!empty($annotations['options'])) {
            $moleculeAttributes = array_merge($moleculeAttributes, $annotations['options']);
        }

        $fillWith = [];

        if (!empty($moleculeAttributes)) {
            foreach ($moleculeAttributes as $key => $moleculeAttribute) {
//Point C : Loop through each attribute to try to get the best value for each one.
                $key_ = str_replace($moleculeName . ':', '', $key);

                if ($moleculeAttribute['component'] === 'InnerBlocks' && !empty($content)) {
                    $fillWith[$key_] = $content;
                } elseif (isset($attributes[$key])) {
                    $val = $attributes[$key];

                    if ($moleculeAttribute['component'] === 'MediaUpload') {
                        $frags = explode('.', $val);
                        $ext   = strtolower(end($frags));

                        //Est-ce que c'est une image ?
                        if (in_array($ext, ['jpg', 'png', 'gif', 'jpeg', 'avif', 'webp','svg'])) {
                            $size = !empty($moleculeAttribute['componentAttributes']) && !empty($moleculeAttribute['componentAttributes']['size']) ? $moleculeAttribute['componentAttributes']['size']
                                : 'medium';
                            $val = Medias::mediaAtSize($val, $size);
                        }
                    }

                    $fillWith[$key_] = $val;
                }
            }
        }

        $getMarkupOpts = [];

        if (!empty($attributes['className'])) {
            $getMarkupOpts['itemAttributes']['class'] = explode(' ', $attributes['className']);
        }

//Point D : fill the component with the data then get its markup back.
        return $molecule->fillWith($fillWith)->getMarkup($getMarkupOpts);

    }

Here again I’ve commented with notable points the most interesting areas. What this render method essentially does is to retrieve the component to use, then fill it with the administered data, then get its markup back for output.

Conclusion

I faced a real challenge abstracting the JavaScript part as I’m not a React expert. To be honest it’s surely not a flawless implementation. But apart from that, the overall solution does exactly what we wanted at the beginning, and it works like a charm so we’re all pretty happy with the result.

Any member of the team and not just the senior JavaScript developers can now quickly create simple blocks by adding documented annotations on component attributes, and that improved our development speed greatly on those components.

I can’t put a complete code repository of this solution online at the time, but I’m not sure that does matter because what’s important here seems to be the journey more than the final implementation. I’ve tried to provide as much code samples and comments in the article to get you an overall idea of the solution.

Hopefully it will get someone an inspiration to build the same sort of thing in your own organisation if need be.