Switching from Shortcodes to Blocks, part 2

In the first post of this series, we discussed transforming a simple shortcode to a block, and using shortcode content for a block attribute value. In this post, we’ll get into a more difficult case: using the shortcode inner content (the text in between the opening and closings tags), as inner blocks for the new block. Our block is going to act as a wrapper for these nested blocks, also known as innerBlocks.

Here’s an example shortcode that will be our transform starting point:

[slidetoggle initialstate="closed"]Learn more about Lorem Ipsum...[/slidetoggle]

And here’s our basic block, before we add the transform:

registerBlockType('example/slidetoggle', {
    ...
    attributes: {
        initiallyClosed: {
            type: 'boolean',
            default: true
        },
    },
    edit: function(props) {
       return (
          <Fragment>
             <button>Click to toggle</button>
             <div className="slide-toggle">
                <InnerBlocks />
             </div>
          </Fragment>
       );
   },
   save: function({attributes}) {
      const initialState = (attributes.initiallyClosed) ? 'start-open' : '';
      return (
         <Fragment>
            <button>Click to toggle</button>
            <div className={ `slide-toggle ${initialState}`}>
              <InnerBlocks.Content />
            </div>
         </Fragment>
      );
   },
   ...
}

The Problem

In the previous post we looked at just one kind of transform, the “shortcode” type. There are other kinds of block transforms available as well, including “raw” and “block” types. When you use those types of transforms, you can also use a “transform” function which gives you more granular control over how the transform is executed. However, when using the “shortcode” transform type, you don’t get access to that function, and therefore don’t have granular control over how the transform is managed. The “shortcode” transform type only allows us to configure, not to explicitly define it’s output.

The Solution

At this time, the best way to solve this issue is a two step transform:

The first transform is from the raw html (or classic block) to the built-in shortcode block. That will change this:

into this:

That is core functionality, and no special transform code is needed.

To effect the second transform, we’ll have to write a transformer configuration of type: "block" which can read from that shortcode block and transform it into our custom block. Here’s what that might look like:

transforms: {
   from: [
      {
         type: 'block',
         blocks: ['core/shortcode'],
         isMatch: function( {text} ) {
            return /^\[slidetoggle /.test(text);
         },
         transform: ({text}) => {

            const initialStateVal = getAttributeValue('slidetoggle', 'initialstate', text);
            const initiallyClosed = ( initialStateVal == 'closed' );

            const content = getInnerContent('slidetoggle', text);

            const innerBlocks = wp.blocks.rawHandler({
              HTML: content,
            });

            return wp.blocks.createBlock('example/slidetoggle', {
              initiallyClosed: initiallyClosed,
            }, innerBlocks);
         },
      },

Let’s break that down a little bit.

The blocks key is an array of block types that can be transformed from.

The isMatch key is a function which allows us to look at the text in the shortcode block and decide if our transform should be available (by returning a boolean). In our example, we use a regular expression to check if the value in the shortcode block begins with [slidetoggle .

The transform key is a function which manually handles the transform and returns a block as its output. Let’s look at what’s in that function in a little more detail.

The transform function

The transform of type: "shortcode" parses the textual shortcode for us, but when we write our own manual handler, we have to parse all that on our own.

There are a couple of helper functions I’ve defined elsewhere in my code (for easier reuse), namely getAttributeValue and getInnerContent.

+see the getAttributeValue() and getInnerContent() helper functions

The getAttributeValue function accepts the shortcode tag, the attribute name, and the block of text to parse:

/**
 * Get the value for a shortcode attribute, whether it's enclosed in double quotes, single
 * quotes, or no quotes.
 * 
 * @param  {string} tag     The shortcode name
 * @param  {string} att     The attribute name
 * @param  {string} content The text which includes the shortcode
 *                          
 * @return {string}         The attribute value or an empty string.
 */
export const getAttributeValue = function(tag, att, content){
	// In string literals, slashes need to be double escaped
	// 
	//    Match  attribute="value"
	//    \[tag[^\]]*      matches opening of shortcode tag 
	//    att="([^"]*)"    captures value inside " and "
	var re = new RegExp(`\\[${tag}[^\\]]* ${att}="([^"]*)"`, 'im');
	var result = content.match(re);
	if( result != null && result.length > 0 )
		return result[1];

	//    Match  attribute='value'
	//    \[tag[^\]]*      matches opening of shortcode tag 
	//    att="([^"]*)"    captures value inside ' and ''
	re = new RegExp(`\\[${tag}[^\\]]* ${att}='([^']*)'`, 'im');
	result = content.match(re);
	if( result != null && result.length > 0 )
		return result[1];

	//    Match  attribute=value
	//    \[tag[^\]]*      matches opening of shortcode tag 
	//    att="([^"]*)"    captures a shortcode value provided without 
        //                     quotes, as in [me color=green]
	re = new RegExp(`\\[${tag}[^\\]]* ${att}=([^\\s]*)\\s`, 'im');
	result = content.match(re);
	if( result != null && result.length > 0 )
	   return result[1];
	return false;
};

The getInnerContent function accepts the shortcode tag and the block of text to parse:

/**
 * Get the inner content of a shortcode, if any.
 * 
 * @param  {string} tag         The shortcode tag
 * @param  {string} content      The text which includes the shortcode. 
 * @param  {bool}   shouldAutoP  Whether or not to filter return value with autop
 * 
 * @return {string}      An empty string if no inner content, or if the
 *                       shortcode is self-closing (no end tag). Otherwise
 *                       returns the inner content.
 */
export const getInnerContent = function(tag, content, shouldAutoP=true) {
   //   \[tag[^\]]*?]    matches opening shortcode tag with or 
                              without attributes, (not greedy)
   //   ([\S\s]*?)       matches anything in between shortcodes tags, 
                              including line breaks and other shortcodes
   //   \[\/tag]         matches end shortcode tag
   // remember, double escaping for string literals inside RegExp
   const re = new RegExp(`\\[${tag}[^\\]]*?]([\\S\\s]*?)\\[\\/${tag}]`, 'i');
   var result = content.match(re);
   if( result == null || result.length < 1 )
      return '';

   if( shouldAutoP == true)
      result[1] = wp.autop(result[1]);

   return result[1];
};

Note that this getShortcodeContent function requires the wp-autop script as a dependency.

So, we use getAttributeValue to parse out our attributes, and getInnerContent to get the inner content.

transform: ({text}) => {

   const initialStateVal = getAttributeValue('slidetoggle', 'initialstate', text);
   const initiallyClosed = ( initialStateVal == 'closed' );

   const content = getInnerContent('slidetoggle', text);
   ...

The content variable now holds a string of the inner html content. We’ll need to run that through wp’s block parser to change it into a block or blocks. Here we use rawHandler:

transform: ({text}) => {
   ...

   const innerBlocks = wp.blocks.rawHandler({
      HTML: content
   });
   ...

Lastly we use createBlockto make the final output. That function takes three parameters: the block slug (in our case example/slidetoggle), the block attributes object, and the inner blocks:

transform: ({text}) => {
   ...

   return wp.blocks.createBlock( 'example/slidetoggle', {
         initiallyClosed: initiallyClosed,
      }, innerBlocks);
 },

Return the result of that function, and the function is complete. Now you should have the ability to click the transform button on a shortcode block to change it into your own block while transferring the shortcode’s inner content to the block’s innerBlocks.

Conclusion

Hopefully this work-around won’t always be necessary, but until then if this helped you or if you have any questions, let me know in the comments section. Thanks for reading!