“Crear una vez, publicar en todas partes” con WordPress

Publicado: 2022-03-10
Resumen rápido ↬ El término COPE ("Crear una vez, publicar en todas partes") es una metodología para publicar nuestro contenido en diferentes salidas (sitio web, sitio AMP, correo electrónico, aplicaciones, etc.) al tener una única fuente de verdad para todos ellos . Exploremos cómo implementar COPE usando WordPress.

COPE es una estrategia para reducir la cantidad de trabajo necesario para publicar nuestro contenido en diferentes medios, como sitios web, correo electrónico, aplicaciones y otros. Iniciado por primera vez por NPR, logra su objetivo al establecer una única fuente de verdad para el contenido que se puede usar para todos los diferentes medios.

Tener contenido que funcione en todas partes no es una tarea baladí ya que cada medio tendrá sus propios requisitos. Por ejemplo, mientras que HTML es válido para imprimir contenido para la web, este lenguaje no es válido para una aplicación iOS/Android. De manera similar, podemos agregar clases a nuestro HTML para la web, pero estas deben convertirse a estilos para correo electrónico.

La solución a este enigma es separar la forma del contenido: la presentación y el significado del contenido deben desvincularse, y solo el significado se utiliza como única fuente de verdad. Luego, la presentación se puede agregar en otra capa (específica para el medio seleccionado).

Por ejemplo, dada la siguiente pieza de código HTML, <p> es una etiqueta HTML que se aplica principalmente a la web, y el atributo class="align-center" es la presentación (colocar un elemento "en el centro" tiene sentido para un medio basado en pantalla, pero no para uno basado en audio como Amazon Alexa):

 <p class="align-center">Hello world!</p>

Por lo tanto, esta pieza de contenido no se puede usar como una única fuente de verdad y se debe convertir a un formato que separe el significado de la presentación, como la siguiente pieza de código JSON:

 { content: "Hello world!", placement: "center", type: "paragraph" }

Este fragmento de código se puede utilizar como fuente única de verdad para el contenido, ya que a partir de él podemos recrear una vez más el código HTML para usar en la web y obtener un formato apropiado para otros medios.

¡Más después del salto! Continúe leyendo a continuación ↓

¿Por qué WordPress?

WordPress es ideal para implementar la estrategia COPE por varias razones:

  • es versatil
    El modelo de base de datos de WordPress no define un modelo de contenido fijo y rígido; por el contrario, se creó por su versatilidad, lo que permite crear modelos de contenido variados mediante el uso de metacampos, que permiten el almacenamiento de datos adicionales para cuatro entidades diferentes: publicaciones y tipos de publicaciones personalizadas, usuarios, comentarios y taxonomías ( etiquetas y categorías).
  • es poderoso
    WordPress brilla como un CMS (Content Management System), y su ecosistema de complementos permite agregar fácilmente nuevas funcionalidades.
  • Está muy extendido.
    Se estima que 1/3 de los sitios web se ejecutan en WordPress. Entonces, una cantidad considerable de personas que trabajan en la web conocen y pueden usar, es decir, WordPress. No solo desarrolladores, sino también blogueros, vendedores, personal de marketing, etc. Luego, muchas partes interesadas diferentes, sin importar su formación técnica, podrán producir el contenido que actúa como la única fuente de verdad.
  • Es sin cabeza.
    Headless es la capacidad de desacoplar el contenido de la capa de presentación, y es una característica fundamental para implementar COPE (para poder alimentar datos a medios diferentes).

    Desde que se incorporó la API REST de WP en el núcleo a partir de la versión 4.7, y más notablemente desde el lanzamiento de Gutenberg en la versión 5.0 (para la cual se tuvieron que implementar muchos puntos finales de la API REST), WordPress puede considerarse un CMS sin cabeza, ya que la mayoría del contenido de WordPress se puede acceder a través de una API REST por cualquier aplicación construida en cualquier pila.

    Además, WPGraphQL, creado recientemente, integra WordPress y GraphQL, lo que permite enviar contenido de WordPress a cualquier aplicación que utilice esta API cada vez más popular. Finalmente, mi propio proyecto PoP agregó recientemente una implementación de una API para WordPress que permite exportar los datos de WordPress como formatos nativos REST, GraphQL o PoP.
  • Tiene Gutenberg , un editor basado en bloques que ayuda mucho a la implementación de COPE porque se basa en el concepto de bloques (como se explica en las secciones a continuación).

Blobs versus bloques para representar información

Un blob es una sola unidad de información almacenada en conjunto en la base de datos. Por ejemplo, escribir la publicación de blog a continuación en un CMS que se basa en blobs para almacenar información almacenará el contenido de la publicación de blog en una sola entrada de base de datos, que contiene el mismo contenido:

 <p>Look at this wonderful tango:</p> <figure> <iframe width="951" height="535" src="https://www.youtube.com/embed/sxm3Xyutc1s" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> <figcaption>An exquisite tango performance</figcaption> </figure>

Como se puede apreciar, los fragmentos de información importantes de esta publicación de blog (como el contenido del párrafo y la URL, las dimensiones y atributos del video de Youtube) no son fácilmente accesibles: si queremos recuperar cualquiera de ellos por sí solos, necesitamos analizar el código HTML para extraerlos, lo que está lejos de ser una solución ideal.

Los bloques actúan de manera diferente. Al representar la información como una lista de bloques, podemos almacenar el contenido de una forma más semántica y accesible. Cada bloque transmite su propio contenido y sus propias propiedades que pueden depender de su tipo (por ejemplo, ¿es quizás un párrafo o un video?).

Por ejemplo, el código HTML anterior podría representarse como una lista de bloques como esta:

 { [ 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; encrypted-media; gyroscope; picture-in-picture", caption: "An exquisite tango performance" ] }

A través de esta forma de representar la información, podemos utilizar fácilmente cualquier dato por sí solo y adaptarlo al medio específico en el que debe mostrarse. Por ejemplo, si queremos extraer todos los videos de la publicación del blog para mostrarlos en un sistema de entretenimiento para automóvil, simplemente podemos iterar todos los bloques de información, seleccionar aquellos con type="embed" y provider="Youtube" y extraer el URL de ellos. De manera similar, si queremos mostrar el video en un Apple Watch, no debemos preocuparnos por las dimensiones del video, por lo que podemos ignorar los atributos width y height de una manera sencilla.

Cómo implementa Gutenberg los bloques

Antes de la versión 5.0 de WordPress, WordPress usaba blobs para almacenar el contenido de las publicaciones en la base de datos. A partir de la versión 5.0, WordPress incluye Gutenberg, un editor basado en bloques, que permite la forma mejorada de procesar el contenido mencionado anteriormente, lo que representa un avance hacia la implementación de COPE. Desafortunadamente, Gutenberg no ha sido diseñado para este caso de uso específico, y su representación de la información es diferente a la que acabamos de describir para los bloques, lo que genera varios inconvenientes que tendremos que solucionar.

Primero echemos un vistazo a cómo se guarda la publicación de blog descrita anteriormente a través de Gutenberg:

 <!-- 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 -->

A partir de este fragmento de código, podemos hacer las siguientes observaciones:

Los bloques se guardan todos juntos en la misma entrada de la base de datos

Hay dos bloques en el código anterior:

 <!-- 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 -->

Con la excepción de los bloques globales (también llamados "reutilizables"), que tienen una entrada propia en la base de datos y se puede hacer referencia a ellos directamente a través de sus ID, todos los bloques se guardan juntos en la entrada de la publicación del blog en la tabla wp_posts .

Por lo tanto, para recuperar la información de un bloque específico, primero necesitaremos analizar el contenido y aislar todos los bloques entre sí. Convenientemente, WordPress proporciona la función parse_blocks($content) para hacer precisamente esto. Esta función recibe una cadena que contiene el contenido de la publicación del blog (en formato HTML) y devuelve un objeto JSON que contiene los datos de todos los bloques incluidos.

El tipo de bloque y los atributos se transmiten a través de comentarios HTML

Cada bloque está delimitado con una etiqueta inicial <!-- wp:{block-type} {block-attributes-encoded-as-JSON} --> y una etiqueta final <!-- /wp:{block-type} --> que (al ser comentarios HTML) aseguran que esta información no será visible cuando se muestre en un sitio web. Sin embargo, no podemos mostrar la publicación del blog directamente en otro medio, ya que el comentario HTML puede ser visible y aparecer como contenido distorsionado. Sin embargo, esto no es gran cosa, ya que después de analizar el contenido a través de la función parse_blocks($content) , los comentarios HTML se eliminan y podemos operar directamente con los datos del bloque como un objeto JSON.

Los bloques contienen HTML

El bloque de párrafo tiene "<p>Look at this wonderful tango:</p>" como contenido, en lugar de "Look at this wonderful tango:" . Por lo tanto, contiene código HTML (etiquetas <p> y </p> ) que no es útil para otros medios y, como tal, debe eliminarse, por ejemplo, a través de la función PHP strip_tags($content) .

Al eliminar las etiquetas, podemos mantener aquellas etiquetas HTML que transmiten explícitamente información semántica, como las etiquetas <strong> y <em> (en lugar de sus contrapartes <b> y <i> que se aplican solo a un medio basado en pantalla), y eliminar todas las demás etiquetas. Esto se debe a que existe una gran posibilidad de que las etiquetas semánticas también se puedan interpretar correctamente para otros medios (por ejemplo, Amazon Alexa puede reconocer las etiquetas <strong> y <em> y cambiar su voz y entonación en consecuencia al leer un texto). Para hacer esto, invocamos la función strip_tags con un segundo parámetro que contiene las etiquetas permitidas y lo colocamos dentro de una función de ajuste por conveniencia:

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

El título del video se guarda dentro del HTML y no como un atributo

Como se puede ver en el bloque de video de Youtube, la leyenda "An exquisite tango performance" se almacena dentro del código HTML (encerrado por la etiqueta <figcaption /> ) pero no dentro del objeto de atributos codificados en JSON. Como consecuencia, para extraer el título, necesitaremos analizar el contenido del bloque, por ejemplo a través de una expresión regular:

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

Este es un obstáculo que debemos superar para extraer todos los metadatos de un bloque de Gutenberg. Esto sucede en varios bloques; dado que no todos los metadatos se guardan como atributos, primero debemos identificar cuáles son estos metadatos y luego analizar el contenido HTML para extraerlos bloque por bloque y pieza por pieza.

En cuanto a COPE, esto representa una oportunidad perdida de tener una solución realmente óptima. Se podría argumentar que la opción alternativa tampoco es la ideal, ya que duplicaría la información, almacenándola tanto dentro del HTML como en un atributo, lo que viola el principio DRY ( D on't R epeat Yourself). Sin embargo, esta infracción ya se produce: por ejemplo, el atributo className contiene el valor "wp-embed-aspect-16-9 wp-has-aspect-ratio" , que también está impreso dentro del contenido, en la class atributo HTML.

Agregar contenido a través de Gutenberg
Adición de contenido a través de Gutenberg (vista previa grande)

Implementando COPE

Nota: he lanzado esta funcionalidad, incluido todo el código que se describe a continuación, como complemento de WordPress Bloquear metadatos. Le invitamos a instalarlo y jugar con él para que pueda probar el poder de COPE. El código fuente está disponible en este repositorio de GitHub.

Ahora que sabemos cómo se ve la representación interna de un bloque, procedamos a implementar COPE a través de Gutenberg. El procedimiento constará de los siguientes pasos:

  1. Debido a que la función parse_blocks($content) devuelve un objeto JSON con niveles anidados, primero debemos simplificar esta estructura.
  2. Iteramos todos los bloques y, para cada uno, identificamos sus metadatos y los extraemos, transformándolos en un formato independiente del medio en el proceso. Los atributos que se agregan a la respuesta pueden variar según el tipo de bloque.
  3. Finalmente hacemos que los datos estén disponibles a través de una API (REST/GraphQL/PoP).

Implementemos estos pasos uno por uno.

1. Simplificando la estructura del objeto JSON

El objeto JSON devuelto por la función parse_blocks($content) tiene una arquitectura anidada, en la que los datos de los bloques normales aparecen en el primer nivel, pero faltan los datos de un bloque reutilizable al que se hace referencia (solo se agregan los datos del bloque de referencia), y los datos de bloques anidados (que se agregan dentro de otros bloques) y de bloques agrupados (donde se pueden agrupar varios bloques) aparecen en 1 o más subniveles. Esta arquitectura dificulta el procesamiento de los datos del bloque de todos los bloques del contenido del post, ya que por un lado faltan algunos datos y por el otro no sabemos a priori bajo cuántos niveles se encuentran los datos. Además, hay un separador de bloques colocado en cada par de bloques, sin contenido, que se puede ignorar de forma segura.

Por ejemplo, la respuesta obtenida de una publicación que contiene un bloque simple, un bloque global, un bloque anidado que contiene un bloque simple y un grupo de bloques simples, en ese orden, es la siguiente:

 [ // Simple block { "blockName": "core/image", "attrs": { "id": 70, "sizeSlug": "large" }, "innerBlocks": [], "innerHTML": "\n<figure class=\"wp-block-image size-large\"><img src=\"https://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=\"https://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=\"https://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=\"https://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=\"https://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=\"https://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" ] } ]

Una mejor solución es tener todos los datos en el primer nivel, por lo que la lógica para iterar a través de todos los datos del bloque se simplifica enormemente. Por lo tanto, debemos obtener los datos de estos bloques reutilizables/anidados/agrupados, y agregarlos también en el primer nivel. Como se puede ver en el código JSON anterior:

  • El bloque divisor vacío tiene el atributo "blockName" con valor NULL
  • La referencia a un bloque reutilizable se define a través $block["attrs"]["ref"]
  • Los bloques anidados y de grupo definen sus bloques contenidos en $block["innerBlocks"]

Por lo tanto, el siguiente código PHP elimina los bloques divisores vacíos, identifica los bloques reutilizables/anidados/agrupados y agrega sus datos al primer nivel, y elimina todos los datos de todos los subniveles:

 /** * 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' property if it exists (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); }

Llamando a la función get_block_data($content) pasando el contenido de la publicación ( $post->post_content ) como parámetro, ahora obtenemos la siguiente respuesta:

 [[ { "blockName": "core/image", "attrs": { "id": 70, "sizeSlug": "large" }, "innerHTML": "\n<figure class=\"wp-block-image size-large\"><img src=\"https://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=\"https://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=\"https://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=\"https://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=\"https://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=\"https://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" ] } ]

Aunque no es estrictamente necesario, es muy útil crear un punto final de la API REST para generar el resultado de nuestra nueva función get_block_data($content) , que nos permitirá comprender fácilmente qué bloques están contenidos en una publicación específica y cómo están. estructurado. El siguiente código agrega dicho punto final en /wp-json/block-metadata/v1/data/{POST_ID} :

 /** * Define REST endpoint to visualize a post's block data */ add_action('rest_api_init', function () { register_rest_route('block-metadata/v1', 'data/(?P \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', array('status' => 404)); } $block_data = get_block_data($post->post_content); $response = new WP_REST_Response($block_data); $response->set_status(200); return $response; } /** * Define REST endpoint to visualize a post's block data */ add_action('rest_api_init', function () { register_rest_route('block-metadata/v1', 'data/(?P \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', array('status' => 404)); } $block_data = get_block_data($post->post_content); $response = new WP_REST_Response($block_data); $response->set_status(200); return $response; }

Para verlo en acción, consulte este enlace que exporta los datos para esta publicación.

2. Extracción de todos los metadatos de bloque en un formato mediano agnóstico

En esta etapa, tenemos datos de bloque que contienen código HTML que no es apropiado para COPE. Por lo tanto, debemos eliminar las etiquetas HTML no semánticas de cada bloque para convertirlo en un formato independiente del medio.

Podemos decidir cuáles son los atributos que se deben extraer según el tipo de bloque (por ejemplo, extraer la propiedad de alineación de texto para bloques de "paragraph" , la propiedad de URL de video para el bloque "youtube embed" , etc.) .

Como vimos anteriormente, no todos los atributos se guardan realmente como atributos de bloque sino dentro del contenido interno del bloque, por lo tanto, para estas situaciones, necesitaremos analizar el contenido HTML usando expresiones regulares para extraer esos metadatos.

Después de inspeccionar todos los bloques enviados a través del núcleo de WordPress, decidí no extraer los metadatos de los siguientes:

"core/columns"
"core/column"
"core/cover"
Estos se aplican solo a medios basados ​​en pantalla y (al ser bloques anidados) son difíciles de manejar.
"core/html" Este solo tiene sentido para web.
"core/table"
"core/button"
"core/media-text"
No tenía ni idea de cómo representar sus datos de una manera medianamente agnóstica o si tenía sentido.

Esto me deja con los siguientes bloques, para los cuales procederé a extraer sus metadatos:

  • 'core/paragraph'
  • 'core/image'
  • 'core-embed/youtube' (como representante de todos los bloques 'core-embed' )
  • 'core/heading'
  • 'core/gallery'
  • 'core/list'
  • 'core/audio'
  • 'core/file'
  • 'core/video'
  • 'core/code'
  • 'core/preformatted'
  • 'core/quote' & 'core/pullquote'
  • 'core/verse'

Para extraer los metadatos, creamos la función get_block_metadata($block_data) que recibe una matriz con los datos del bloque para cada bloque (es decir, la salida de nuestra función get_block_data implementada previamente) y, según el tipo de bloque (proporcionado en la propiedad "blockName" ), decide qué atributos se requieren y cómo extraerlos:

 /** * 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 ...: $blockMeta = ... break; case ...: $blockMeta = ... break; ... } if ($blockMeta) { $ret[] = [ 'blockName' => $block['blockName'], 'meta' => $blockMeta, ]; } } return $ret; }

Procedamos a extraer los metadatos para cada tipo de bloque, uno por uno:

“core/paragraph”

Simplemente elimine las etiquetas HTML del contenido y las líneas de corte finales.

 case 'core/paragraph': $blockMeta = [ 'content' => trim(strip_html_tags($block['innerHTML'])), ]; break;

'core/image'

El bloque tiene una identificación que hace referencia a un archivo multimedia cargado o, si no, la fuente de la imagen debe extraerse de debajo de <img src="..."> . Varios atributos (título, linkDestination, enlace, alineación) son opcionales.

 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;

Tiene sentido crear funciones extract_image_src , extract_caption y extract_link ya que sus expresiones regulares se usarán una y otra vez para varios bloques. Tenga en cuenta que un título en Gutenberg puede contener enlaces ( <a href="..."> ); sin embargo, al llamar a strip_html_tags , estos se eliminan del título.

Aunque lamentable, encuentro esta práctica inevitable, ya que no podemos garantizar un enlace para trabajar en plataformas no web. Por lo tanto, aunque el contenido está ganando universalidad ya que puede usarse para diferentes medios, también está perdiendo especificidad, por lo que su calidad es más pobre en comparación con el contenido creado y personalizado para la plataforma en particular.

 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; }

'core-embed/youtube'

Simplemente recupere la URL del video de los atributos del bloque y extraiga su título del contenido HTML, si existe.

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

'core/heading'

Tanto el tamaño del encabezado (h1, h2,…, h6) como el texto del encabezado no son atributos, por lo que estos deben obtenerse del contenido HTML. Tenga en cuenta que, en lugar de devolver la etiqueta HTML para el encabezado, el atributo de size es simplemente una representación equivalente, que es más agnóstica y tiene más sentido para las plataformas que no son web.

 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;

'core/gallery'

Desafortunadamente, para la galería de imágenes no he podido extraer los títulos de cada imagen, ya que estos no son atributos, y extraerlos a través de una expresión regular simple puede fallar: Si hay un título para el primer y tercer elemento, pero ninguno para el segundo, entonces no sabría qué título corresponde a qué imagen (y no he dedicado el tiempo para crear una expresión regular compleja). Del mismo modo, en la lógica a continuación, siempre estoy recuperando el tamaño de imagen "full" , sin embargo, este no tiene que ser el caso, y no sé cómo se puede inferir el tamaño más apropiado.

 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;

'core/list'

Simplemente transforme los elementos <li> en una matriz de elementos.

 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;

'core/audio'

Obtenga la URL del archivo multimedia cargado correspondiente.

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

'core/file'

Mientras que la URL del archivo es un atributo, su texto debe extraerse del contenido interno.

 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;

'core/video'

Obtenga la URL del video y todas las propiedades para configurar cómo se reproduce el video a través de una expresión regular. Si Gutenberg alguna vez cambia el orden en el que se imprimen estas propiedades en el código, esta expresión regular dejará de funcionar, evidenciando uno de los problemas de no agregar metadatos directamente a través de los atributos del bloque.

 case 'core/video': $matches = []; preg_match('/ 

'core/code'

Simply extract the code from within <code /> .

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

'core/preformatted'

Similar to <code /> , but we must watch out that Gutenberg hardcodes a class too.

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

'core/quote' and 'core/pullquote'

We must convert all inner <p /> tags to their equivalent generic "\n" character.

 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;

'core/verse'

Similar situation to <pre /> .

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

3. Exporting Data Through An API

Now that we have extracted all block metadata, we need to make it available to our different mediums, through an API. WordPress has access to the following APIs:

  • REST, through the WP REST API (integrated in WordPress core)
  • GraphQL, through WPGraphQL
  • PoP, through its implementation for WordPress

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

DESCANSO

The following code creates endpoint /wp-json/block-metadata/v1/metadata/{POST_ID} which exports all block metadata for a specific post:

 /** * 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 \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', array('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; } /** * 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 \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', array('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; }

To see it working, this link (corresponding to this blog post) displays the metadata for blocks of all the types analyzed earlier on.

GraphQL (Through WPGraphQL)

GraphQL works by setting-up schemas and types which define the structure of the content, from which arises this API's power to fetch exactly the required data and nothing else. Setting-up schemas works very well when the structure of the object has a unique representation.

In our case, however, the metadata returned by a new field "block_metadata" (which calls our newly-created function get_block_metadata ) depends on the specific block type, so the structure of the response can vary wildly; GraphQL provides a solution to this issue through a Union type, allowing to return one among a set of different types. However, its implementation for all different variations of the metadata structure has proved to be a lot of work, and I quit along the way .

As an alternative (not ideal) solution, I decided to provide the response by simply encoding the JSON object through a new 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); } ] ); });

PoP

Note: This functionality is available on its own GitHub repo.

The final API is called PoP, which is a little-known project I've been working on for several years now. I have recently converted it into a full-fledged API, with the capacity to produce a response compatible with both REST and GraphQL, and which even benefits from the advantages from these 2 APIs, at the same time: no under/over-fetching of data, like in GraphQL, while being cacheable on the server-side and not susceptible to DoS attacks, like REST. It offers a mix between the two of them: REST-like endpoints with GraphQL-like queries.

The block metadata is made available through the API through the following code:

 class PostFieldValueResolver extends AbstractDBDataFieldValueResolver { public static function getClassesToAttachTo(): array { return array(\PoP\Posts\FieldResolver::class); } public function resolveValue(FieldResolverInterface $fieldResolver, $resultItem, string $fieldName, array $fieldArgs = []) { $post = $resultItem; switch ($fieldName) { case 'block-metadata': $block_data = \Leoloso\BlockMetadata\Data::get_block_data($post->post_content); $block_metadata = \Leoloso\BlockMetadata\Metadata::get_block_metadata($block_data); // Filter by blockName if ($blockName = $fieldArgs['blockname']) { $block_metadata = array_filter( $block_metadata, function($block) use($blockName) { return $block['blockName'] == $blockName; } ); } return $block_metadata; } return parent::resolveValue($fieldResolver, $resultItem, $fieldName, $fieldArgs); } }

To see it in action, this link displays the block metadata (+ ID, title and URL of the post, and the ID and name of its author, a la GraphQL) for a list of posts.

Además, de forma similar a los argumentos de GraphQL, nuestra consulta se puede personalizar a través de argumentos de campo, lo que permite obtener solo los datos que tienen sentido para una plataforma específica. Por ejemplo, si deseamos extraer todos los videos de Youtube agregados a todas las publicaciones, podemos agregar un modificador (blockname:core-embed/youtube) para block-metadata en la URL del punto final, como en este enlace. O si queremos extraer todas las imágenes de una publicación específica, podemos agregar un modificador (blockname:core/image) como en este otro enlace|id|título).

Conclusión

La estrategia COPE ("Crear una vez, publicar en todas partes") nos ayuda a reducir la cantidad de trabajo necesario para crear varias aplicaciones que deben ejecutarse en diferentes medios (web, correo electrónico, aplicaciones, asistentes domésticos, realidad virtual, etc.) al crear una fuente única de verdad para nuestro contenido. En cuanto a WordPress, aunque siempre ha brillado como un sistema de gestión de contenido, la implementación de la estrategia COPE históricamente ha demostrado ser un desafío.

Sin embargo, un par de desarrollos recientes han hecho que sea cada vez más factible implementar esta estrategia para WordPress. Por un lado, desde la integración en el núcleo de la API REST de WP, y más notablemente desde el lanzamiento de Gutenberg, se puede acceder a la mayoría del contenido de WordPress a través de las API, lo que lo convierte en un verdadero sistema sin cabeza. Por otro lado, Gutenberg (que es el nuevo editor de contenido predeterminado) está basado en bloques, lo que hace que todos los metadatos dentro de una publicación de blog sean fácilmente accesibles para las API.

Como consecuencia, implementar COPE para WordPress es sencillo. En este artículo, hemos visto cómo hacerlo, y todo el código relevante está disponible a través de varios repositorios. Aunque la solución no es óptima (ya que implica mucho código HTML de análisis), todavía funciona bastante bien, con la consecuencia de que el esfuerzo necesario para lanzar nuestras aplicaciones a múltiples plataformas se puede reducir considerablemente. ¡Felicitaciones a eso!