“Create Once, Publish Everywhere” with WordPress

Leonardo Losoviz

"COPE"

=

"Create Once,

Publish Everywhere"

😝

What is COPE?

  • It a strategy for reducing the amount of work needed to publish our content into different platforms or mediums
  • Websites
  • Email
  • Apps
  • VR/AR
  • Apple Watch
  • Scoreboards/Giant screens
  • Podcast Players
  • Home assistants (Amazon Alexa)
  • In-car entertainment systems
  • Others

How does COPE work?

  • It establishes a single source of truth for content, which can be used for all the different mediums
  • However, a single piece of content doesn't normally work everywhere:
  • HTML is valid for the web, but not for an iOS/Android app
  • Classes in HTML for the web, but styles for email
  • class="text-center" doesn't make sense for an audio-based medium

How does COPE work?

  • The solution is to separate form from content
  • The presentation and the meaning of the content must be decoupled
  • Only the meaning is used as the single source of truth
  • The presentation can be added in another layer, specific to the selected medium
<p class="align-center">Hello world!</p>
{
  type: "paragraph",
  content: "Hello world!",
  placement: "center"
}

Why WordPress? 🤔

  • WordPress is ideal to implement COPE because it is...

Versatile

The DB enables the creation of distinct content models through the use of meta fields

Powerful

It shines as a CMS, featuring a plugin ecosystem to add new functionalities

Widespread

Most people working on the web know about/know how to use it, including non-techies

Headless

Through APIs (WP REST API, WPGraphQL, PoP) its content is accessible to any application

It has a block-based editor, Gutenberg

Blocks enable to easily export the post content metadata...

Blobs vs Blocks

A blob is a single unit of information stored all together in the database

<p>Look at this wonderful tango:</p>
<figure>
  <iframe width="951" height="535" src="https://www.youtube.com/embed/sxm3Xyutc1s" frameborder="0" allow="autoplay;" allowfullscreen></iframe>
  <figcaption>An exquisite tango performance</figcaption>
</figure>
  • The important bits of information (the content in the paragraph, and the URL, the dimensions and attributes of the Youtube video) are not readily accessible (we need to parse the HTML code to retrieve them)

Blobs vs Blocks

Before version 5.0, WordPress used blobs

Blobs vs Blocks

A block conveys its own content and properties as metadata, customized to its type (paragraph/video/etc)

{
  [
    type: "paragraph",
    content: "Look at this wonderful tango:"
  ],
  [
    type: "embed",
    provider: "Youtube",
    url: "https://www.youtube.com/embed/sxm3Xyutc1s",
    width: 951,
    height: 535,
    frameborder: 0,
    allowfullscreen: true,
    allow: "accelerometer; autoplay;",
    caption: "An exquisite tango performance"
  ]  
}

We can easily use any piece of data on its own, and adapt it for the specific medium where it must be displayed

Blobs vs Blocks

Since version 5.0, WordPress uses blocks (via Gutenberg)

Gutenberg blocks

  • Gutenberg was not designed specifically for COPE
  • Its representation of the information is different to the one just described for blocks
<!-- wp:paragraph --> 
<p>Look at this wonderful tango:</p> 
<!-- /wp:paragraph -->  
<!-- wp:core-embed/youtube {"url":"https://www.youtube.com/embed/sxm3Xyutc1s","type":"rich","providerNameSlug":"embed-handler","className":"wp-embed-aspect-16-9 wp-has-aspect-ratio"} --> 
<figure class="wp-block-embed-youtube wp-block-embed is-type-rich is-provider-embed-handler wp-embed-aspect-16-9 wp-has-aspect-ratio">
  <div class="wp-block-embed__wrapper"> https://www.youtube.com/embed/sxm3Xyutc1s </div>
  <figcaption>An exquisite tango performance</figcaption>
</figure> 
<!-- /wp:core-embed/youtube -->

We can make several observations...

Gutenberg blocks

<!-- wp:paragraph --> 
<p>Look at this wonderful tango:</p> 
<!-- /wp:paragraph -->  
<!-- wp:core-embed/youtube {"url":"https://www.youtube.com/embed/sxm3Xyutc1s","type":"rich","providerNameSlug":"embed-handler","className":"wp-embed-aspect-16-9 wp-has-aspect-ratio"} --> 
<figure class="wp-block-embed-youtube wp-block-embed is-type-rich is-provider-embed-handler wp-embed-aspect-16-9 wp-has-aspect-ratio">
  <div class="wp-block-embed__wrapper"> https://www.youtube.com/embed/sxm3Xyutc1s </div>
  <figcaption>An exquisite tango performance</figcaption>
</figure> 
<!-- /wp:core-embed/youtube -->

1. Blocks are saved all together in the same database entry

  • With the exception of global (or "reusable") blocks, all blocks are saved together in the blog post's entry in table wp_posts
  • WordPress provides function parse_blocks($content) to
    • parse the blog post content (in HTML format)
    • return a JSON object with the data for all contained blocks

Gutenberg blocks

<!-- wp:paragraph --> 
<p>Look at this wonderful tango:</p> 
<!-- /wp:paragraph -->  
<!-- wp:core-embed/youtube {"url":"https://www.youtube.com/embed/sxm3Xyutc1s","type":"rich","providerNameSlug":"embed-handler","className":"wp-embed-aspect-16-9 wp-has-aspect-ratio"} --> 
<figure class="wp-block-embed-youtube wp-block-embed is-type-rich is-provider-embed-handler wp-embed-aspect-16-9 wp-has-aspect-ratio">
  <div class="wp-block-embed__wrapper"> https://www.youtube.com/embed/sxm3Xyutc1s </div>
  <figcaption>An exquisite tango performance</figcaption>
</figure> 
<!-- /wp:core-embed/youtube -->

2. Block type/attributes are stored using HTML comments

  • Each block is delimited with a starting tag <!-- wp:{block-type} {block-attributes-encoded-as-JSON} --> and an ending tag <!-- /wp:{block-type} -->
  • HTML comments are not visible for the web, but we don't know for other mediums
  • No big deal, since we work with JSON object returned from function parse_blocks($content)

Gutenberg blocks

<!-- wp:paragraph --> 
<p>Look at this wonderful tango:</p> 
<!-- /wp:paragraph -->  
<!-- wp:core-embed/youtube {"url":"https://www.youtube.com/embed/sxm3Xyutc1s","type":"rich","providerNameSlug":"embed-handler","className":"wp-embed-aspect-16-9 wp-has-aspect-ratio"} --> 
<figure class="wp-block-embed-youtube wp-block-embed is-type-rich is-provider-embed-handler wp-embed-aspect-16-9 wp-has-aspect-ratio">
  <div class="wp-block-embed__wrapper"> https://www.youtube.com/embed/sxm3Xyutc1s </div>
  <figcaption>An exquisite tango performance</figcaption>
</figure> 
<!-- /wp:core-embed/youtube -->

3. Blocks contain HTML

  • The paragraph block has "<p>Look at this wonderful tango:</p>" as its content, instead of "Look at this wonderful tango:"
  • The HTML code is not useful for other mediums
  • It must be removed, for instance through PHP function strip_tags($content)
  • Can keep semantic tags though (<strong>, <em>)

⚠️

Gutenberg blocks

<!-- wp:paragraph --> 
<p>Look at this wonderful tango:</p> 
<!-- /wp:paragraph -->  
<!-- wp:core-embed/youtube {"url":"https://www.youtube.com/embed/sxm3Xyutc1s","type":"rich","providerNameSlug":"embed-handler","className":"wp-embed-aspect-16-9 wp-has-aspect-ratio"} --> 
<figure class="wp-block-embed-youtube wp-block-embed is-type-rich is-provider-embed-handler wp-embed-aspect-16-9 wp-has-aspect-ratio">
  <div class="wp-block-embed__wrapper"> https://www.youtube.com/embed/sxm3Xyutc1s </div>
  <figcaption>An exquisite tango performance</figcaption>
</figure> 
<!-- /wp:core-embed/youtube -->

4. The video's caption is stored in HTML, not as an attribute

  • To extract the caption, we need to parse the block content through a regular expression

⚠️❌

function extract_caption($content) 
{
  $matches = [];
  preg_match('/<figcaption>(.*?)<\/figcaption>/', $content, $matches);
  if ($caption = $matches[1]) {
    return strip_tags($caption, '<strong><em>');
  }
  return null;
}
  • Custom regex to extract attributes, block-by-block

Implementing COPE

WordPress plugin "Block Metadata" released with all the COPE implementation code

Implementing COPE

The procedure requires the following steps:

  1. Simplifying the structure of the JSON object returned by function parse_blocks($content)
  2. Extracting the pieces of metadata from each block, transforming them into a medium-agnostic format
  3. Making the data available through an API (REST / GraphQL / PoP).

1. Simplifying the structure of the JSON object

[
  // Simple block
  {
    "blockName": "core/image",
    "attrs": {
      "id": 70,
      "sizeSlug": "large"
    },
    "innerBlocks": [],
    "innerHTML": "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/sandwich-1024x614.jpg\" alt=\"\" class=\"wp-image-70\"/><figcaption>This is a normal block</figcaption></figure>\n",
    "innerContent": [
      "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/sandwich-1024x614.jpg\" alt=\"\" class=\"wp-image-70\"/><figcaption>This is a normal block</figcaption></figure>\n"
    ]
  },
  // Empty block divider
  {
    "blockName": null,
    "attrs": [],
    "innerBlocks": [],
    "innerHTML": "\n\n",
    "innerContent": [
      "\n\n"
    ]
  },
  // Reference to reusable block
  {
    "blockName": "core/block",
    "attrs": {
      "ref": 218
    },
    "innerBlocks": [],
    "innerHTML": "",
    "innerContent": []
  },
  // Empty block divider
  {
    "blockName": null,
    "attrs": [],
    "innerBlocks": [],
    "innerHTML": "\n\n",
    "innerContent": [
      "\n\n"
    ]
  },
  // Nested block
  {
    "blockName": "core/columns",
    "attrs": [],
    // Contained nested blocks
    "innerBlocks": [
      {
        "blockName": "core/column",
        "attrs": [],
        // Contained nested blocks
        "innerBlocks": [
          {
            "blockName": "core/image",
            "attrs": {
              "id": 69,
              "sizeSlug": "large"
            },
            "innerBlocks": [],
            "innerHTML": "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/espresso-1024x614.jpg\" alt=\"\" class=\"wp-image-69\"/></figure>\n",
            "innerContent": [
              "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/espresso-1024x614.jpg\" alt=\"\" class=\"wp-image-69\"/></figure>\n"
            ]
          }
        ],
        "innerHTML": "\n<div class=\"wp-block-column\"></div>\n",
        "innerContent": [
          "\n<div class=\"wp-block-column\">",
          null,
          "</div>\n"
        ]
      },
      {
        "blockName": "core/column",
        "attrs": [],
        // Contained nested blocks
        "innerBlocks": [
          {
            "blockName": "core/paragraph",
            "attrs": [],
            "innerBlocks": [],
            "innerHTML": "\n<p>This is how I wake up every morning</p>\n",
            "innerContent": [
              "\n<p>This is how I wake up every morning</p>\n"
            ]
          }
        ],
        "innerHTML": "\n<div class=\"wp-block-column\"></div>\n",
        "innerContent": [
          "\n<div class=\"wp-block-column\">",
          null,
          "</div>\n"
        ]
      }
    ],
    "innerHTML": "\n<div class=\"wp-block-columns\">\n\n</div>\n",
    "innerContent": [
      "\n<div class=\"wp-block-columns\">",
      null,
      "\n\n",
      null,
      "</div>\n"
    ]
  },
  // Empty block divider
  {
    "blockName": null,
    "attrs": [],
    "innerBlocks": [],
    "innerHTML": "\n\n",
    "innerContent": [
      "\n\n"
    ]
  },
  // Block group
  {
    "blockName": "core/group",
    "attrs": [],
    // Contained grouped blocks
    "innerBlocks": [
      {
        "blockName": "core/image",
        "attrs": {
          "id": 71,
          "sizeSlug": "large"
        },
        "innerBlocks": [],
        "innerHTML": "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/coffee-1024x614.jpg\" alt=\"\" class=\"wp-image-71\"/><figcaption>First element of the group</figcaption></figure>\n",
        "innerContent": [
          "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/coffee-1024x614.jpg\" alt=\"\" class=\"wp-image-71\"/><figcaption>First element of the group</figcaption></figure>\n"
        ]
      },
      {
        "blockName": "core/paragraph",
        "attrs": [],
        "innerBlocks": [],
        "innerHTML": "\n<p>Second element of the group</p>\n",
        "innerContent": [
          "\n<p>Second element of the group</p>\n"
        ]
      }
    ],
    "innerHTML": "\n<div class=\"wp-block-group\"><div class=\"wp-block-group__inner-container\">\n\n</div></div>\n",
    "innerContent": [
      "\n<div class=\"wp-block-group\"><div class=\"wp-block-group__inner-container\">",
      null,
      "\n\n",
      null,
      "</div></div>\n"
    ]
  }
]

The JSON object returned by function parse_blocks($content) has missing data (for referenced reusable blocks), and data under multiple levels (for nested and group blocks)

1. Simplifying the structure of the JSON object

/**
 * Export all (Gutenberg) blocks' data from a WordPress post
 */
function get_block_data($content, $remove_divider_block = true)
{
  // Parse the blocks, and convert them into a single-level array
  $ret = [];
  $blocks = parse_blocks($content);
  recursively_add_blocks($ret, $blocks);

  // Maybe remove blocks without name
  if ($remove_divider_block) {
    $ret = remove_blocks_without_name($ret);
  }

  // Remove 'innerBlocks' properties for all blocks (since that code was copied to the first level, it is currently duplicated)
  foreach ($ret as &$block) {
      unset($block['innerBlocks']);
  }

  return $ret;
}

/**
 * Remove the blocks without name, such as the empty block divider
 */
function remove_blocks_without_name($blocks)
{
  return array_values(array_filter(
    $blocks,
    function($block) {
      return $block['blockName'];
    }
  ));
}

/**
 * Add block data (including global and nested blocks) into the first level of the array
 */
function recursively_add_blocks(&$ret, $blocks) 
{  
  foreach ($blocks as $block) {
    // Global block: add the referenced block instead of this one
    if ($block['attrs']['ref']) {
      $ret = array_merge(
      	$ret,
      	recursively_render_block_core_block($block['attrs'])
      );
    }
    // Normal block: add it directly
    else {
      $ret[] = $block;
    }
    // If it contains nested or grouped blocks, add them too
    if ($block['innerBlocks']) {
      recursively_add_blocks($ret, $block['innerBlocks']);
    }
  }
}

/**
 * Function based on `render_block_core_block`
 */
function recursively_render_block_core_block($attributes) 
{
  if (empty($attributes['ref'])) {
    return [];
  }

  $reusable_block = get_post($attributes['ref']);
  if (!$reusable_block || 'wp_block' !== $reusable_block->post_type) {
    return [];
  }

  if ('publish' !== $reusable_block->post_status || ! empty($reusable_block->post_password)) {
    return [];
  }

  return get_block_data($reusable_block->post_content);
}

We must fetch the data for the reusable/nested/grouped blocks, and add it on the first level of the JSON object

1. Simplifying the structure of the JSON object

[
  {
    "blockName": "core/image",
    "attrs": {
      "id": 70,
      "sizeSlug": "large"
    },
    "innerHTML": "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/sandwich-1024x614.jpg\" alt=\"\" class=\"wp-image-70\"/><figcaption>This is a normal block</figcaption></figure>\n",
    "innerContent": [
      "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/sandwich-1024x614.jpg\" alt=\"\" class=\"wp-image-70\"/><figcaption>This is a normal block</figcaption></figure>\n"
    ]
  },
  {
    "blockName": "core/paragraph",
    "attrs": [],
    "innerHTML": "\n<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>\n",
    "innerContent": [
      "\n<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>\n"
    ]
  },
  {
    "blockName": "core/columns",
    "attrs": [],
    "innerHTML": "\n<div class=\"wp-block-columns\">\n\n</div>\n",
    "innerContent": [
      "\n<div class=\"wp-block-columns\">",
      null,
      "\n\n",
      null,
      "</div>\n"
    ]
  },
  {
    "blockName": "core/column",
    "attrs": [],
    "innerHTML": "\n<div class=\"wp-block-column\"></div>\n",
    "innerContent": [
      "\n<div class=\"wp-block-column\">",
      null,
      "</div>\n"
    ]
  },
  {
    "blockName": "core/image",
    "attrs": {
      "id": 69,
      "sizeSlug": "large"
    },
    "innerHTML": "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/espresso-1024x614.jpg\" alt=\"\" class=\"wp-image-69\"/></figure>\n",
    "innerContent": [
      "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/espresso-1024x614.jpg\" alt=\"\" class=\"wp-image-69\"/></figure>\n"
    ]
  },
  {
    "blockName": "core/column",
    "attrs": [],
    "innerHTML": "\n<div class=\"wp-block-column\"></div>\n",
    "innerContent": [
      "\n<div class=\"wp-block-column\">",
      null,
      "</div>\n"
    ]
  },
  {
    "blockName": "core/paragraph",
    "attrs": [],
    "innerHTML": "\n<p>This is how I wake up every morning</p>\n",
    "innerContent": [
      "\n<p>This is how I wake up every morning</p>\n"
    ]
  },
  {
    "blockName": "core/group",
    "attrs": [],
    "innerHTML": "\n<div class=\"wp-block-group\"><div class=\"wp-block-group__inner-container\">\n\n</div></div>\n",
    "innerContent": [
      "\n<div class=\"wp-block-group\"><div class=\"wp-block-group__inner-container\">",
      null,
      "\n\n",
      null,
      "</div></div>\n"
    ]
  },
  {
    "blockName": "core/image",
    "attrs": {
      "id": 71,
      "sizeSlug": "large"
    },
    "innerHTML": "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/coffee-1024x614.jpg\" alt=\"\" class=\"wp-image-71\"/><figcaption>First element of the group</figcaption></figure>\n",
    "innerContent": [
      "\n<figure class=\"wp-block-image size-large\"><img src=\"http://localhost/wp-content/uploads/2017/12/coffee-1024x614.jpg\" alt=\"\" class=\"wp-image-71\"/><figcaption>First element of the group</figcaption></figure>\n"
    ]
  },
  {
    "blockName": "core/paragraph",
    "attrs": [],
    "innerHTML": "\n<p>Second element of the group</p>\n",
    "innerContent": [
      "\n<p>Second element of the group</p>\n"
    ]
  }
]

The resulting JSON object contains all data for all blocks in the post, and is easy to iterate

1. Simplifying the structure of the JSON object

// Define REST endpoint to visualize a post's block data
add_action('rest_api_init', function () {
  register_rest_route('block-metadata/v1', 'data/(?P<post_id>\d+)', [
    'methods'  => 'GET',
    'callback' => 'get_post_blocks'
  ]);
});
function get_post_blocks($request) 
{
  $post = get_post($request['post_id']);
  if (!$post) {
    return new WP_Error('empty_post', 'There is no post with this ID', ['status' => 404]);
  }

  $block_data = get_block_data($post->post_content);
  $response = new WP_REST_Response($block_data);
  $response->set_status(200);
  return $response;
}

We can create an endpoint to access this data through the REST API: /wp-json/block-metadata/v1/data/{POST_ID}

1. Simplifying the structure of the JSON object

Let's check the results:

2. Extracting metadata into a medium-agnostic format

  • At this stage, we have block data containing HTML code, which is not appropriate for COPE
  • We must strip the non-semantic HTML tags for each block, turning it into a medium-agnostic format
  • We decide what attributes to extract on a block-type by block-type basis:
    • Text alignment property for "paragraph" blocks
    • Video URL property for the "youtube embed" block
  • Attributes saved within the block inner content (and not as block attributes) must be extracted through regex

2. Extracting metadata into a medium-agnostic format

Not all block types can work with COPE

  • "core/columns", "core/column" and "core/cover" apply only to screen-based mediums and, being nested blocks, are difficult to deal with
  • "core/html" only makes sense for web
  • "core/table", "core/button" and "core/media-text" can't be easily represented in a medium-agnostic format, and may not even make sense

2. Extracting metadata into a medium-agnostic format

The following core blocks can be processed:

  • "core/paragraph"
  • "core/image"
  • "core-embed/youtube"
    ("core-embed")
  • "core/heading"
  • "core/gallery"
  • "core/list"
  • "core/audio"
  • "core/file"
  • "core/video"
  • "core/code"
  • "core/preformatted"
  • "core/quote"
  • "core/pullquote"
  • "core/verse"

2. Extracting metadata into a medium-agnostic format

To extract the metadata, we create function get_block_metadata($block_data). Based on the block type, it defines what attributes to extract and how to do it

/**
 * Process all (Gutenberg) blocks' metadata into a medium-agnostic format from a WordPress post
 */
function get_block_metadata($block_data)
{
  $ret = [];
  foreach ($block_data as $block) {
    $blockMeta = null;
    switch ($block['blockName']) {
      case 'core/paragraph':
        $blockMeta = [
          'content' => trim(strip_html_tags($block['innerHTML'])),
        ];
        break;

      case 'core/image':
        $blockMeta = [];
        // If inserting the image from the Media Manager, it has an ID
        if ($block['attrs']['id'] && $img = wp_get_attachment_image_src($block['attrs']['id'], $block['attrs']['sizeSlug'])) {
          $blockMeta['img'] = [
            'src' => $img[0],
            'width' => $img[1],
            'height' => $img[2],
          ];
        }
        elseif ($src = extract_image_src($block['innerHTML'])) {
          $blockMeta['src'] = $src;
        }
        if ($caption = extract_caption($block['innerHTML'])) {
          $blockMeta['caption'] = $caption;
        }
        if ($linkDestination = $block['attrs']['linkDestination']) {
	        $blockMeta['linkDestination'] = $linkDestination;
	        if ($link = extract_link($block['innerHTML'])) {
	          $blockMeta['link'] = $link;
	        }
	      }
        if ($align = $block['attrs']['align']) {
          $blockMeta['align'] = $align;
        }
        break;

      case 'core-embed/youtube':
        $blockMeta = [
          'url' => $block['attrs']['url'],
        ];
        if ($caption = extract_caption($block['innerHTML'])) {
          $blockMeta['caption'] = $caption;
        }
        break;

      case 'core/heading':
        $matches = [];
        preg_match('/<h([1-6])>(.*?)<\/h([1-6])>/', $block['innerHTML'], $matches);
        $sizes = [
          null,
          'xxl',
          'xl',
          'l',
          'm',
          'sm',
          'xs',
        ];
        $blockMeta = [
          'size' => $sizes[$matches[1]],
          'heading' => $matches[2],
        ];
        break;

      case 'core/gallery':
        $imgs = [];
        foreach ($block['attrs']['ids'] as $img_id) {
          $img = wp_get_attachment_image_src($img_id, 'full');
          $imgs[] = [
            'src' => $img[0],
            'width' => $img[1],
            'height' => $img[2],
          ];
        }
        $blockMeta = [
          'imgs' => $imgs,
        ];
        break;

      case 'core/list':
        $matches = [];
        preg_match_all('/<li>(.*?)<\/li>/', $block['innerHTML'], $matches);
        if ($items = $matches[1]) {
          $blockMeta = [
            'items' => array_map('strip_html_tags', $items),
          ];
        }
        break;

      case 'core/audio':
        $blockMeta = [
          'src' => wp_get_attachment_url($block['attrs']['id']),
        ];
        break;

      case 'core/file':
        $href = $block['attrs']['href'];
        $matches = [];
        preg_match('/<a href="'.str_replace('/', '\/', $href).'">(.*?)<\/a>/', $block['innerHTML'], $matches);
        $blockMeta = [
          'href' => $href,
          'text' => strip_html_tags($matches[1]),
        ];
        break;

      case 'core/video':
        $matches = [];
        preg_match('/<video (autoplay )?(controls )?(loop )?(muted )?(poster="(.*?)" )?src="(.*?)"( playsinline)?><\/video>/', $block['innerHTML'], $matches);
        $blockMeta = [
          'src' => $matches[7],
        ];
        if ($poster = $matches[6]) {
          $blockMeta['poster'] = $poster;
        }
        // Video settings
        $settings = [];
        if ($matches[1]) {
          $settings[] = 'autoplay';
        }
        if ($matches[2]) {
          $settings[] = 'controls';
        }
        if ($matches[3]) {
          $settings[] = 'loop';
        }
        if ($matches[4]) {
          $settings[] = 'muted';
        }
        if ($matches[8]) {
          $settings[] = 'playsinline';
        }
        if ($settings) {
          $blockMeta['settings'] = $settings;
        }
        if ($caption = extract_caption($block['innerHTML'])) {
          $blockMeta['caption'] = $caption;
        }
        break;

      case 'core/code':
        $matches = [];
        preg_match('/<code>(.*?)<\/code>/is', $block['innerHTML'], $matches);
        $blockMeta = [
          'code' => $matches[1],
        ];
        break;

      case 'core/preformatted':
        $matches = [];
        preg_match('/<pre class="wp-block-preformatted">(.*?)<\/pre>/is', $block['innerHTML'], $matches);
        $blockMeta = [
          'text' => strip_html_tags($matches[1]),
        ];
        break;

      case 'core/quote':
      case 'core/pullquote':
        $matches = [];
        $regexes = [
          'core/quote' => '/<blockquote class=\"wp-block-quote\">(.*?)<\/blockquote>/',
          'core/pullquote' => '/<figure class=\"wp-block-pullquote\"><blockquote>(.*?)<\/blockquote><\/figure>/',
        ];
        preg_match($regexes[$block['blockName']], $block['innerHTML'], $matches);
        if ($quoteHTML = $matches[1]) {
          preg_match_all('/<p>(.*?)<\/p>/', $quoteHTML, $matches);
          $blockMeta = [
            'quote' => strip_html_tags(implode('\n', $matches[1])),
          ];
          preg_match('/<cite>(.*?)<\/cite>/', $quoteHTML, $matches);
          if ($cite = $matches[1]) {
            $blockMeta['cite'] = strip_html_tags($cite);
          }
        }
        break;

      case 'core/verse':
        $matches = [];
        preg_match('/<pre class="wp-block-verse">(.*?)<\/pre>/is', $block['innerHTML'], $matches);
        $blockMeta = [
          'text' => strip_html_tags($matches[1]),
        ];
        break;
    }

    if ($blockMeta) {
      $ret[] = [
        'blockName' => $block['blockName'],
        'meta' => $blockMeta,
      ];
    }
  }

  return $ret;
}

function strip_html_tags($content)
{
  return strip_tags($content, '<strong><em>');
}

function extract_caption($innerHTML)
{
  $matches = [];
  preg_match('/<figcaption>(.*?)<\/figcaption>/', $innerHTML, $matches);
  if ($caption = $matches[1]) {
    return strip_html_tags($caption);
  }
  return null;
}

function extract_link($innerHTML)
{
  $matches = [];
  preg_match('/<a href="(.*?)">(.*?)<\/a>/', $innerHTML, $matches);
  if ($link = $matches[1]) {
    return $link;
  }
  return null;
}

function extract_image_src($innerHTML)
{
  $matches = [];
  preg_match('/<img src="(.*?)"/', $innerHTML, $matches);
  if ($src = $matches[1]) {
    return $src;
  }
  return null;
}

3. Exporting data through an API

  • After extracting all block metadata, we need to make it available to our different mediums, through an API
  • WordPress has access to the following APIs:

Let's see how to export the data through each of them

3. Exporting data through an API: REST

Endpoint: /wp-json/block-metadata/v1/metadata/{POST_ID}

// Define REST endpoints to export the blocks' metadata for a specific post
add_action('rest_api_init', function () {
  register_rest_route('block-metadata/v1', 'metadata/(?P<post_id>\d+)', [
    'methods'  => 'GET',
    'callback' => 'get_post_block_meta'
  ]);
});
function get_post_block_meta($request) 
{
  $post = get_post($request['post_id']);
  if (!$post) {
    return new WP_Error('empty_post', 'There is no post with this ID', ['status' => 404]);
  }

  $block_data = get_block_data($post->post_content);
  $block_metadata = get_block_metadata($block_data);
  $response = new WP_REST_Response($block_metadata);
  $response->set_status(200);
  return $response;
}

3. Exporting data through an API: REST

Let's check the results:

3. Exporting data through an API: GraphQL

Field: "jsonencoded_block_metadata"

/**
 * Define WPGraphQL field "jsonencoded_block_metadata"
 */
add_action('graphql_register_types', function() {
  register_graphql_field(
    'Post',
    'jsonencoded_block_metadata',
    [
      'type'        => 'String',
      'description' => __('Post blocks encoded as JSON', 'wp-graphql'),
      'resolve'     => function($post) {
        $post = get_post($post->ID);
        $block_data = get_block_data($post->post_content);
        $block_metadata = get_block_metadata($block_data);
        return json_encode($block_metadata);
      }
    ]
  );
});

3. Exporting data through an API: PoP

Endpoint: /posts/api/?query=blockMetadata

class FieldValueResolverUnit extends \PoP\Engine\AbstractDBDataFieldValueResolverUnit
{
  public static function getClassesToAttachTo()
  {
    return array(\PoP\Posts\FieldValueResolver_Posts::class);
  }

  public function getValue($fieldValueResolver, $resultitem, string $fieldName, array $fieldAtts = [])
  {
    $post = $resultitem;
    switch ($fieldName) {
      case 'blockMetadata':
        $block_data = get_block_data($post->post_content);
        $block_metadata = get_block_metadata($block_data);

        // Filter by blockName
        if ($blockName = $fieldAtts['blockName']) {
          $block_metadata = array_filter(
            $block_metadata,
            function($block) use($blockName) {
              return $block['blockName'] == $blockName;
            }
          );
        }
        return $block_metadata;
    }

    return parent::getValue($fieldValueResolver, $resultitem, $fieldName, $fieldAtts);
  }
}

3. Exporting data through an API: PoP

Let's check the results:

3. Exporting data through an API: PoP

Code available under repo:

[
  {
    "blockName": "core/paragraph",
    "meta": {
      "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Dolor sed viverra ipsum nunc aliquet bibendum enim. In massa tempor nec feugiat. Nunc aliquet bibendum enim facilisis gravida. Nisl nunc mi ipsum faucibus vitae aliquet nec ullamcorper. Amet luctus venenatis lectus magna fringilla. Volutpat maecenas volutpat blandit aliquam etiam erat velit scelerisque in. Egestas egestas fringilla phasellus faucibus scelerisque eleifend. Sagittis orci a scelerisque purus semper eget duis. Nulla pharetra diam sit amet nisl suscipit. Sed adipiscing diam donec adipiscing tristique risus nec feugiat in. Fusce ut placerat orci nulla. Pharetra vel turpis nunc eget lorem dolor. Tristique senectus et netus et malesuada."
    }
  },
  {
    "blockName": "core/image",
    "meta": {
      "src": "https://ps.w.org/gutenberg/assets/banner-1544x500.jpg"
    }
  },
  {
    "blockName": "core/paragraph",
    "meta": {
      "content": "<em>Etiam tempor orci eu lobortis elementum nibh tellus molestie. Neque  egestas congue quisque egestas. Egestas integer eget aliquet nibh  praesent tristique. Vulputate mi sit amet mauris. Sodales neque sodales  ut etiam sit. Dignissim suspendisse in est ante in. Volutpat commodo sed  egestas egestas. Felis donec et odio pellentesque diam. Pharetra vel  turpis nunc eget lorem dolor sed viverra. Porta nibh venenatis cras sed  felis eget. Aliquam ultrices sagittis orci a. Dignissim diam quis enim  lobortis. Aliquet porttitor lacus luctus accumsan. Dignissim convallis  aenean et tortor at risus viverra adipiscing at.</em>"
    }
  },
  {
    "blockName": "core-embed/youtube",
    "meta": {
      "url": "https://www.youtube.com/watch?v=9pT-q0SSYow",
      "caption": "<strong>This is the video caption</strong>"
    }
  },
  {
    "blockName": "core/quote",
    "meta": {
      "quote": "Saramago sonogo\\nEn la lista del longo",
      "cite": "<em>alguno</em>"
    }
  },
  {
    "blockName": "core/image",
    "meta": {
      "src": "https://ps.w.org/gutenberg/assets/banner-1544x500.jpg"
    }
  },
  {
    "blockName": "core/heading",
    "meta": {
      "size": "xl",
      "heading": "Some heading here"
    }
  },
  {
    "blockName": "core/gallery",
    "meta": {
      "imgs": [
        {
          "src": "https://newapi.getpop.org/wp/wp-content/uploads/2019/08/Sample-jpg-image-50kb.jpg",
          "width": 300,
          "height": 300
        },
        {
          "src": "https://newapi.getpop.org/wp/wp-content/uploads/2019/08/setting-rest-fields.png",
          "width": 1738,
          "height": 246
        },
        {
          "src": "https://newapi.getpop.org/wp/wp-content/uploads/2019/08/Sample-jpg-image-100kb.jpg",
          "width": 689,
          "height": 689
        },
        {
          "src": "https://newapi.getpop.org/wp/wp-content/uploads/2019/08/banner-1544x500.jpg",
          "width": 1544,
          "height": 500
        }
      ]
    }
  },
  {
    "blockName": "core/list",
    "meta": {
      "items": [
        "First element",
        "Second element",
        "Third element"
      ]
    }
  },
  {
    "blockName": "core/audio",
    "meta": {
      "src": false
    }
  },
  {
    "blockName": "core/paragraph",
    "meta": {
      "content": "Watch out the contrast!"
    }
  },
  {
    "blockName": "core/file",
    "meta": {
      "href": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
      "text": "Contributor-Day <strong>download</strong> file"
    }
  },
  {
    "blockName": "core/video",
    "meta": {
      "src": "https://www.sample-videos.com/video123/mp4/720/big_buck_bunny_720p_1mb.mp4",
      "settings": [
        "autoplay",
        "muted",
        "playsinline"
      ],
      "caption": "Caption for <em>the</em> video"
    }
  },
  {
    "blockName": "core/code",
    "meta": {
      "code": "function recursive_parse_blocks( $content ) {\n\t$ret = [];\n\t$blocks = parse_blocks( $content );\n\trecursive_add_blocks($ret, $blocks);\n\treturn $ret;\n}"
    }
  },
  {
    "blockName": "core/preformatted",
    "meta": {
      "text": "Some pre-formated text"
    }
  },
  {
    "blockName": "core/pullquote",
    "meta": {
      "quote": "The will to win, the desire to succeed, the urge to reach your full potential… these are the keys that will unlock the door to personal excellence.",
      "cite": "Confucius"
    }
  },
  {
    "blockName": "core/verse",
    "meta": {
      "text": "It is easy to hate and it is difficult to love. This is how the whole scheme of things works. All good things are difficult to achieve; and bad things are very easy to get."
    }
  },
  {
    "blockName": "core/paragraph",
    "meta": {
      "content": "First grouped paragraph"
    }
  },
  {
    "blockName": "core/paragraph",
    "meta": {
      "content": "Second grouped paragraph"
    }
  },
  {
    "blockName": "core/paragraph",
    "meta": {
      "content": "Here is the media header"
    }
  },
  {
    "blockName": "core/paragraph",
    "meta": {
      "content": "And some text"
    }
  }
]
<!-- wp:block {"ref":1500} /-->

<!-- wp:image {"id":262,"sizeSlug":"large"} -->
<figure class="wp-block-image size-large"><img src="https://ps.w.org/gutenberg/assets/banner-1544x500.jpg" alt="" class="wp-image-262"/></figure>
<!-- /wp:image -->

<!-- wp:paragraph -->
<p><em>Etiam tempor orci eu lobortis elementum nibh tellus molestie. Neque  egestas congue quisque egestas. Egestas integer eget aliquet nibh  praesent tristique. Vulputate mi sit amet mauris. Sodales neque sodales  ut etiam sit. Dignissim suspendisse in est ante in. Volutpat commodo sed  egestas egestas. Felis donec et odio pellentesque diam. Pharetra vel  turpis nunc eget lorem dolor sed viverra. Porta nibh venenatis cras sed  felis eget. Aliquam ultrices sagittis orci a. Dignissim diam quis enim  lobortis. Aliquet porttitor lacus luctus accumsan. Dignissim convallis  aenean et tortor at risus viverra adipiscing at.</em></p>
<!-- /wp:paragraph -->

<!-- wp:core-embed/youtube {"url":"https://www.youtube.com/watch?v=9pT-q0SSYow","type":"video","providerNameSlug":"youtube","className":"wp-embed-aspect-16-9 wp-has-aspect-ratio"} -->
<figure class="wp-block-embed-youtube wp-block-embed is-type-video is-provider-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio"><div class="wp-block-embed__wrapper">
https://www.youtube.com/watch?v=9pT-q0SSYow
</div><figcaption><strong>This is the video caption</strong></figcaption></figure>
<!-- /wp:core-embed/youtube -->

<!-- wp:columns -->
<div class="wp-block-columns"><!-- wp:column -->
<div class="wp-block-column"><!-- wp:quote -->
<blockquote class="wp-block-quote"><p>Saramago sonogo</p><p>En la lista del longo</p><cite><em><a href="https://yahoo.com">alguno</a></em></cite></blockquote>
<!-- /wp:quote --></div>
<!-- /wp:column -->

<!-- wp:column -->
<div class="wp-block-column"><!-- wp:image {"id":70,"sizeSlug":"large"} -->
<figure class="wp-block-image size-large"><img src="https://ps.w.org/gutenberg/assets/banner-1544x500.jpg" alt="" class="wp-image-70"/></figure>
<!-- /wp:image --></div>
<!-- /wp:column --></div>
<!-- /wp:columns -->

<!-- wp:heading -->
<h2>Some heading here</h2>
<!-- /wp:heading -->

<!-- wp:gallery {"ids":[1502,1505,1503,1504]} -->
<ul class="wp-block-gallery columns-3 is-cropped"><li class="blocks-gallery-item"><figure><img src="https://newapi.getpop.org/wp/wp-content/uploads/2019/08/Sample-jpg-image-50kb.jpg" alt="" data-id="1502" data-link="https://newapi.getpop.org/uncategorized/cope-with-wordpress-post-demo-containing-plenty-of-blocks/attachment/sample-jpg-image-50kb/" class="wp-image-1502"/><figcaption>Caption 1st image</figcaption></figure></li><li class="blocks-gallery-item"><figure><img src="https://newapi.getpop.org/wp/wp-content/uploads/2019/08/setting-rest-fields-1024x145.png" alt="" data-id="1505" data-link="https://newapi.getpop.org/uncategorized/cope-with-wordpress-post-demo-containing-plenty-of-blocks/attachment/setting-rest-fields/" class="wp-image-1505"/></figure></li><li class="blocks-gallery-item"><figure><img src="https://newapi.getpop.org/wp/wp-content/uploads/2019/08/Sample-jpg-image-100kb.jpg" alt="" data-id="1503" data-link="https://newapi.getpop.org/uncategorized/cope-with-wordpress-post-demo-containing-plenty-of-blocks/attachment/sample-jpg-image-100kb/" class="wp-image-1503"/><figcaption>Caption 3rd image</figcaption></figure></li><li class="blocks-gallery-item"><figure><img src="https://newapi.getpop.org/wp/wp-content/uploads/2019/08/banner-1544x500-1024x332.jpg" alt="" data-id="1504" data-link="https://newapi.getpop.org/uncategorized/cope-with-wordpress-post-demo-containing-plenty-of-blocks/attachment/banner-1544x500/" class="wp-image-1504"/><figcaption>Final <strong>caption</strong> <a href="https://getpop.org">for all</a></figcaption></figure></li></ul>
<!-- /wp:gallery -->

<!-- wp:list -->
<ul><li>First element</li><li>Second element</li><li>Third element</li></ul>
<!-- /wp:list -->

<!-- wp:audio {"id":224} -->
<figure class="wp-block-audio"><audio controls src="https://sample-videos.com/audio/mp3/crowd-cheering.mp3"></audio></figure>
<!-- /wp:audio -->

<!-- wp:cover {"url":"https://ps.w.org/gutenberg/assets/banner-1544x500.jpg","id":71} -->
<div class="wp-block-cover has-background-dim" style="background-image:url(https://ps.w.org/gutenberg/assets/banner-1544x500.jpg)"><div class="wp-block-cover__inner-container"><!-- wp:paragraph {"align":"center","placeholder":"Write title…","fontSize":"large"} -->
<p style="text-align:center" class="has-large-font-size">Watch out the contrast!</p>
<!-- /wp:paragraph --></div></div>
<!-- /wp:cover -->

<!-- wp:file {"id":225,"href":"https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf","showDownloadButton":false} -->
<div class="wp-block-file"><a href="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf">Contributor-Day <strong>download</strong> file</a></div>
<!-- /wp:file -->

<!-- wp:video -->
<figure class="wp-block-video"><video autoplay muted poster="" src="https://www.sample-videos.com/video123/mp4/720/big_buck_bunny_720p_1mb.mp4" playsinline></video><figcaption>Caption for <em>the</em> video</figcaption></figure>
<!-- /wp:video -->

<!-- wp:code -->
<pre class="wp-block-code"><code>function recursive_parse_blocks( $content ) {
	$ret = [];
	$blocks = parse_blocks( $content );
	recursive_add_blocks($ret, $blocks);
	return $ret;
}</code></pre>
<!-- /wp:code -->

<p>This is a TinyMCE textarea</p>
<p>The text is formatted</p>

<!-- wp:html -->
<p>This is <strong>HTML</strong> so we don't really need it for <em>mediums</em> other than web!</p>
<!-- /wp:html -->

<!-- wp:preformatted -->
<pre class="wp-block-preformatted">Some pre-formated text</pre>
<!-- /wp:preformatted -->

<!-- wp:pullquote -->
<figure class="wp-block-pullquote"><blockquote><p>The will to win, the desire to succeed, the urge to reach your full potential… these are the keys that will unlock the door to personal excellence.</p><cite>Confucius</cite></blockquote></figure>
<!-- /wp:pullquote -->

<!-- wp:table -->
<figure class="wp-block-table"><table class=""><tbody><tr><td>Content column 1 row 1</td><td>Content column 2 row 1</td></tr><tr><td>Content column 1 row 2</td><td>Content column 2 row 2</td></tr></tbody></table></figure>
<!-- /wp:table -->

<!-- wp:verse -->
<pre class="wp-block-verse">It is easy to hate and it is difficult to love. This is how the whole scheme of things works. All good things are difficult to achieve; and bad things are very easy to get.</pre>
<!-- /wp:verse -->

<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link" href="https://getpop.org">This is a button, onclick goes somewhere</a></div>
<!-- /wp:button -->

<!-- wp:group -->
<div class="wp-block-group"><div class="wp-block-group__inner-container"><!-- wp:paragraph -->
<p>First grouped paragraph</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
<p>Second grouped paragraph</p>
<!-- /wp:paragraph --></div></div>
<!-- /wp:group -->

<!-- wp:media-text {"mediaId":70,"mediaType":"image"} -->
<div class="wp-block-media-text alignwide"><figure class="wp-block-media-text__media"><img src="https://ps.w.org/gutenberg/assets/banner-1544x500.jpg" alt="" class="wp-image-70"/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"Content…","fontSize":"large"} -->
<p class="has-large-font-size">Here is the media header</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph -->
<p>And some text</p>
<!-- /wp:paragraph --></div></div>
<!-- /wp:media-text -->

<!-- wp:separator {"className":"is-style-wide"} -->
<hr class="wp-block-separator is-style-wide"/>
<!-- /wp:separator -->

<!-- wp:spacer {"height":30} -->
<div style="height:30px" aria-hidden="true" class="wp-block-spacer"></div>
<!-- /wp:spacer -->

<!-- wp:core-embed/twitter {"url":"https://twitter.com/losoviz/status/1148250406281105408","type":"rich","providerNameSlug":"twitter","className":""} -->
<figure class="wp-block-embed-twitter wp-block-embed is-type-rich is-provider-twitter"><div class="wp-block-embed__wrapper">
https://twitter.com/losoviz/status/1148250406281105408
</div><figcaption>Some tweet</figcaption></figure>
<!-- /wp:core-embed/twitter -->

Conclusion

  • The COPE ("Create Once, Publish Everywhere") strategy helps create several applications which must run on different platforms or mediums (web, email, apps, home assistants, virtual reality, etc)
  • It aims to create a single source of truth for our content by separating form from presentation

Conclusion

  • Because it is block-based, Gutenberg makes all metadata inside a post readily accessible through APIs
  • Implementing COPE for WordPress is not optimal (for instance, need to parse HTML code), but it is straightforward and works fairly well
  • The effort needed to release our applications to multiple platforms can be greatly reduced!

“Create Once, Publish Everywhere” with WordPress

By Leonardo Losoviz

“Create Once, Publish Everywhere” with WordPress

Presentation for WordCamp Singapore 2019 and Kuala Lumpur 2019

  • 4,087