"Créez une fois, publiez partout" avec WordPress

Publié: 2022-03-10
Résumé rapide ↬ Le terme COPE ("Create Once, Publish Everywhere") est une méthodologie pour publier notre contenu sur différentes sorties (site Web, site AMP, e-mail, applications, etc.) en ayant une seule source de vérité pour chacun d'eux . Explorons comment implémenter COPE en utilisant WordPress.

COPE est une stratégie visant à réduire la quantité de travail nécessaire pour publier notre contenu sur différents supports, tels que le site Web, les e-mails, les applications et autres. D'abord lancé par NPR, il atteint son objectif en établissant une source unique de vérité pour le contenu qui peut être utilisée pour tous les différents supports.

Avoir du contenu qui fonctionne partout n'est pas une mince affaire puisque chaque média aura ses propres exigences. Par exemple, alors que HTML est valide pour imprimer du contenu pour le Web, ce langage n'est pas valide pour une application iOS/Android. De même, nous pouvons ajouter des classes à notre code HTML pour le Web, mais celles-ci doivent être converties en styles pour le courrier électronique.

La solution à cette énigme est de séparer la forme du contenu : la présentation et le sens du contenu doivent être découplés, et seul le sens est utilisé comme unique source de vérité. La présentation peut alors être ajoutée dans un autre calque (spécifique au support sélectionné).

Par exemple, étant donné le morceau de code HTML suivant, le <p> est une balise HTML qui s'applique principalement au Web, et l'attribut class="align-center" est la présentation (placer un élément "au centre" est logique pour un support basé sur écran, mais pas pour un support audio tel qu'Amazon Alexa) :

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

Par conséquent, ce contenu ne peut pas être utilisé comme une source unique de vérité et doit être converti dans un format qui sépare le sens de la présentation, comme le morceau de code JSON suivant :

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

Ce bout de code peut être utilisé comme une source unique de vérité pour le contenu puisqu'à partir de là on peut recréer à nouveau le code HTML à utiliser pour le web, et se procurer un format approprié pour d'autres médiums.

Plus après saut! Continuez à lire ci-dessous ↓

Pourquoi WordPress

WordPress est idéal pour mettre en œuvre la stratégie COPE pour plusieurs raisons :

  • Il est polyvalent.
    Le modèle de base de données WordPress ne définit pas un modèle de contenu fixe et rigide ; au contraire, il a été créé pour la polyvalence, permettant de créer des modèles de contenu variés grâce à l'utilisation de méta-champs, qui permettent le stockage de données supplémentaires pour quatre entités différentes : messages et types de messages personnalisés, utilisateurs, commentaires et taxonomies ( balises et catégories).
  • C'est puissant.
    WordPress brille en tant que CMS (Content Management System), et son écosystème de plugins permet d'ajouter facilement de nouvelles fonctionnalités.
  • Il est répandu.
    On estime que 1/3 des sites Web fonctionnent sur WordPress. Ensuite, un nombre non négligeable de personnes travaillant sur le web connaissent et sont capables d'utiliser, c'est-à-dire WordPress. Non seulement les développeurs, mais aussi les blogueurs, les vendeurs, le personnel marketing, etc. Ensuite, de nombreuses parties prenantes différentes, quelle que soit leur formation technique, seront en mesure de produire le contenu qui agit comme la seule source de vérité.
  • Il est sans tête.
    L'absence de tête est la capacité de découpler le contenu de la couche de présentation, et c'est une caractéristique fondamentale pour la mise en œuvre de COPE (pour pouvoir fournir des données à des supports différents).

    Depuis l'intégration de l'API WP REST dans le noyau à partir de la version 4.7, et plus nettement depuis le lancement de Gutenberg dans la version 5.0 (pour laquelle de nombreux points de terminaison de l'API REST ont dû être implémentés), WordPress peut être considéré comme un CMS sans tête, car la plupart des contenus WordPress est accessible via une API REST par n'importe quelle application construite sur n'importe quelle pile.

    De plus, le WPGraphQL récemment créé intègre WordPress et GraphQL, permettant d'alimenter le contenu de WordPress dans n'importe quelle application utilisant cette API de plus en plus populaire. Enfin, mon propre projet PoP a récemment ajouté une implémentation d'une API pour WordPress qui permet d'exporter les données WordPress aux formats natifs REST, GraphQL ou PoP.
  • Il a Gutenberg , un éditeur basé sur des blocs qui facilite grandement la mise en œuvre de COPE car il est basé sur le concept de blocs (comme expliqué dans les sections ci-dessous).

Blobs versus blocs pour représenter les informations

Un blob est une seule unité d'information stockée ensemble dans la base de données. Par exemple, écrire l'article de blog ci-dessous sur un CMS qui s'appuie sur des blobs pour stocker des informations stockera le contenu de l'article de blog sur une seule entrée de base de données - contenant le même contenu :

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

Comme on peut le constater, les informations importantes de ce billet de blog (telles que le contenu du paragraphe, l'URL, les dimensions et les attributs de la vidéo Youtube) ne sont pas facilement accessibles : si nous voulons récupérer l'une d'entre elles par eux-mêmes, nous devons analyser le code HTML pour les extraire - ce qui est loin d'être une solution idéale.

Les blocs agissent différemment. En représentant les informations sous forme de liste de blocs, nous pouvons stocker le contenu de manière plus sémantique et accessible. Chaque bloc véhicule son propre contenu et ses propres propriétés qui peuvent dépendre de son type (par exemple est-ce peut-être un paragraphe ou une vidéo ?).

Par exemple, le code HTML ci-dessus pourrait être représenté sous la forme d'une liste de blocs comme ceci :

 { [ 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" ] }

Grâce à cette manière de représenter l'information, nous pouvons facilement utiliser n'importe quelle donnée seule et l'adapter au support spécifique où elle doit être affichée. Par exemple, si nous voulons extraire toutes les vidéos du billet de blog pour les afficher sur un système de divertissement automobile, nous pouvons simplement parcourir tous les blocs d'informations, sélectionner ceux avec type="embed" et provider="Youtube" , et extraire le URL d'eux. De même, si nous voulons montrer la vidéo sur une Apple Watch, nous n'avons pas besoin de nous soucier des dimensions de la vidéo, nous pouvons donc ignorer les attributs width et height de manière simple.

Comment Gutenberg implémente les blocs

Avant la version 5.0 de WordPress, WordPress utilisait des blobs pour stocker le contenu des publications dans la base de données. À partir de la version 5.0, WordPress est livré avec Gutenberg, un éditeur basé sur des blocs, permettant la manière améliorée de traiter le contenu mentionné ci-dessus, ce qui représente une percée vers la mise en œuvre de COPE. Malheureusement, Gutenberg n'a pas été conçu pour ce cas d'utilisation spécifique, et sa représentation des informations est différente de celle qui vient d'être décrite pour les blocs, ce qui entraîne plusieurs inconvénients que nous devrons gérer.

Voyons d'abord comment le billet de blog décrit ci-dessus est enregistré via 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 ce bout de code, nous pouvons faire les observations suivantes :

Les blocs sont enregistrés tous ensemble dans la même entrée de base de données

Il y a deux blocs dans le code ci-dessus :

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

À l'exception des blocs globaux (également appelés "réutilisables"), qui ont leur propre entrée dans la base de données et peuvent être référencés directement via leurs identifiants, tous les blocs sont enregistrés ensemble dans l'entrée de l'article de blog dans la table wp_posts .

Par conséquent, pour récupérer les informations d'un bloc spécifique, nous devrons d'abord analyser le contenu et isoler tous les blocs les uns des autres. De manière pratique, WordPress fournit la fonction parse_blocks($content) pour faire exactement cela. Cette fonction reçoit une chaîne contenant le contenu de l'article de blog (au format HTML) et renvoie un objet JSON contenant les données de tous les blocs contenus.

Le type de bloc et les attributs sont transmis via des commentaires HTML

Chaque bloc est délimité par une balise de début <!-- wp:{block-type} {block-attributes-encoded-as-JSON} --> et une balise de fin <!-- /wp:{block-type} --> qui (étant des commentaires HTML) garantissent que ces informations ne seront pas visibles lors de leur affichage sur un site Web. Cependant, nous ne pouvons pas afficher l'article de blog directement sur un autre support, car le commentaire HTML peut être visible, apparaissant comme un contenu tronqué. Ce n'est pas un gros problème, car après avoir analysé le contenu via la fonction parse_blocks($content) , les commentaires HTML sont supprimés et nous pouvons opérer directement avec les données du bloc en tant qu'objet JSON.

Les blocs contiennent du HTML

Le bloc de paragraphe a "<p>Look at this wonderful tango:</p>" comme contenu, au lieu de "Look at this wonderful tango:" . Par conséquent, il contient du code HTML (balises <p> et </p> ) qui n'est pas utile pour d'autres supports, et en tant que tel doit être supprimé, par exemple via la fonction PHP strip_tags($content) .

Lors de la suppression des balises, nous pouvons conserver les balises HTML qui transmettent explicitement des informations sémantiques, telles que les balises <strong> et <em> (au lieu de leurs homologues <b> et <i> qui s'appliquent uniquement à un support sur écran), et supprimer toutes les autres balises. En effet, il y a de grandes chances que les balises sémantiques puissent également être correctement interprétées pour d'autres supports (par exemple, Amazon Alexa peut reconnaître les balises <strong> et <em> , et changer sa voix et son intonation en conséquence lors de la lecture d'un morceau de texte). Pour ce faire, nous invoquons la fonction strip_tags avec un 2e paramètre contenant les balises autorisées, et la plaçons dans une fonction d'habillage pour plus de commodité :

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

La légende de la vidéo est enregistrée dans le code HTML et non en tant qu'attribut

Comme on peut le voir dans le bloc vidéo Youtube, la légende "An exquisite tango performance" est stockée dans le code HTML (entouré par la balise <figcaption /> ) mais pas dans l'objet d'attributs encodé JSON. Par conséquent, pour extraire la légende, nous devrons analyser le contenu du bloc, par exemple via une expression régulière :

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

C'est un obstacle que nous devons surmonter pour extraire toutes les métadonnées d'un bloc Gutenberg. Cela se produit sur plusieurs blocs ; puisque toutes les métadonnées ne sont pas enregistrées en tant qu'attributs, nous devons alors d'abord identifier quelles sont ces métadonnées, puis analyser le contenu HTML pour les extraire bloc par bloc et morceau par morceau.

Concernant COPE, cela représente une chance perdue d'avoir une solution vraiment optimale. On pourrait faire valoir que l'option alternative n'est pas idéale non plus, car elle dupliquerait les informations, les stockant à la fois dans le HTML et en tant qu'attribut, ce qui viole le principe DRY (Don't Repeat Yourself ). Cependant, cette violation a déjà lieu : par exemple, l'attribut className contient la valeur "wp-embed-aspect-16-9 wp-has-aspect-ratio" , qui est également imprimée dans le contenu, sous l'attribut HTML class .

Ajouter du contenu via Gutenberg
Ajout de contenu via Gutenberg ( Grand aperçu )

Mise en œuvre du COPE

Remarque : J'ai publié cette fonctionnalité, y compris tout le code décrit ci-dessous, en tant que plugin WordPress Block Metadata. Vous pouvez l'installer et jouer avec pour avoir un avant-goût de la puissance de COPE. Le code source est disponible dans ce dépôt GitHub.

Maintenant que nous savons à quoi ressemble la représentation interne d'un bloc, procédons à l'implémentation de COPE via Gutenberg. La procédure comportera les étapes suivantes :

  1. Étant donné que la fonction parse_blocks($content) renvoie un objet JSON avec des niveaux imbriqués, nous devons d'abord simplifier cette structure.
  2. Nous itérons tous les blocs et, pour chacun, identifions leurs métadonnées et les extrayons, en les transformant dans un format indépendant du support dans le processus. Les attributs ajoutés à la réponse peuvent varier en fonction du type de bloc.
  3. Nous rendons enfin les données disponibles via une API (REST/GraphQL/PoP).

Mettons en œuvre ces étapes une par une.

1. Simplifier la structure de l'objet JSON

L'objet JSON renvoyé par la fonction parse_blocks($content) a une architecture imbriquée, dans laquelle les données des blocs normaux apparaissent au premier niveau, mais les données d'un bloc réutilisable référencé sont manquantes (seules les données du bloc de référence sont ajoutées), et les données pour les blocs imbriqués (qui sont ajoutés dans d'autres blocs) et pour les blocs groupés (où plusieurs blocs peuvent être regroupés) apparaissent sous 1 ou plusieurs sous-niveaux. Cette architecture rend difficile le traitement des blocs de données de tous les blocs du contenu du post, puisque d'un côté certaines données manquent, et de l'autre on ne sait pas a priori sous combien de niveaux se situent les données. De plus, il y a un séparateur de blocs placé à chaque paire de blocs, ne contenant aucun contenu, qui peut être ignoré en toute sécurité.

Par exemple, la réponse obtenue à partir d'un article contenant un bloc simple, un bloc global, un bloc imbriqué contenant un bloc simple et un groupe de blocs simples, dans cet ordre, est la suivante :

 [ // 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" ] } ]

Une meilleure solution consiste à avoir toutes les données au premier niveau, de sorte que la logique pour parcourir toutes les données de bloc est grandement simplifiée. Par conséquent, nous devons récupérer les données de ces blocs réutilisables/imbriqués/groupés et les ajouter également au premier niveau. Comme on peut le voir dans le code JSON ci-dessus :

  • Le bloc séparateur vide a l'attribut "blockName" avec la valeur NULL
  • La référence à un bloc réutilisable est définie par $block["attrs"]["ref"]
  • Les blocs imbriqués et groupés définissent leurs blocs contenus sous $block["innerBlocks"]

Par conséquent, le code PHP suivant supprime les blocs séparateurs vides, identifie les blocs réutilisables/imbriqués/groupés et ajoute leurs données au premier niveau, et supprime toutes les données de tous les sous-niveaux :

 /** * 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); }

En appelant la fonction get_block_data($content) passant le post content ( $post->post_content ) en paramètre, on obtient maintenant la réponse suivante :

 [[ { "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" ] } ]

Même si ce n'est pas strictement nécessaire, il est très utile de créer un point de terminaison d'API REST pour afficher le résultat de notre nouvelle fonction get_block_data($content) , ce qui nous permettra de comprendre facilement quels blocs sont contenus dans un article spécifique et comment ils sont structuré. Le code ci-dessous ajoute ce point de terminaison sous /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; }

Pour le voir en action, consultez ce lien qui exporte les données de cet article.

2. Extraction de toutes les métadonnées de bloc dans un format indépendant du support

À ce stade, nous avons des blocs de données contenant du code HTML qui n'est pas approprié pour COPE. Par conséquent, nous devons supprimer les balises HTML non sémantiques pour chaque bloc afin de le convertir dans un format indépendant du support.

Nous pouvons décider quels sont les attributs qui doivent être extraits sur une base de type de bloc par type de bloc (par exemple, extraire la propriété d'alignement du texte pour les blocs "paragraph" , la propriété d'URL vidéo pour le bloc "youtube embed" , etc.) .

Comme nous l'avons vu précédemment, tous les attributs ne sont pas réellement enregistrés en tant qu'attributs de bloc mais dans le contenu interne du bloc. Par conséquent, dans ces situations, nous devrons analyser le contenu HTML à l'aide d'expressions régulières afin d'extraire ces éléments de métadonnées.

Après avoir inspecté tous les blocs expédiés via le noyau de WordPress, j'ai décidé de ne pas extraire les métadonnées pour les suivants :

"core/columns"
"core/column"
"core/cover"
Celles-ci ne s'appliquent qu'aux supports sur écran et (étant des blocs imbriqués) sont difficiles à gérer.
"core/html" Celui-ci n'a de sens que pour le web.
"core/table"
"core/button"
"core/media-text"
Je n'avais aucune idée de la façon de représenter leurs données de manière indépendante du support ou même si cela avait du sens.

Cela me laisse avec les blocs suivants, pour lesquels je vais procéder à l'extraction de leurs métadonnées :

  • 'core/paragraph'
  • 'core/image'
  • 'core-embed/youtube' (en tant que représentant de tous les blocs 'core-embed' )
  • 'core/heading'
  • 'core/gallery'
  • 'core/list'
  • 'core/audio'
  • 'core/file'
  • 'core/video'
  • 'core/code'
  • 'core/preformatted'
  • 'core/quote' & 'core/pullquote'
  • 'core/verse'

Pour extraire les métadonnées, nous créons la fonction get_block_metadata($block_data) qui reçoit un tableau avec les données de bloc pour chaque bloc (c'est-à-dire la sortie de notre fonction get_block_data précédemment implémentée) et, selon le type de bloc (fourni sous la propriété "blockName" ), décide quels attributs sont requis et comment les extraire :

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

Procédons à l'extraction des métadonnées pour chaque type de bloc, une par une :

“core/paragraph”

Supprimez simplement les balises HTML du contenu et supprimez les lignes de rupture de fin.

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

'core/image'

Le bloc a soit un ID faisant référence à un fichier multimédia téléchargé ou, sinon, la source de l'image doit être extraite de sous <img src="..."> . Plusieurs attributs (caption, linkDestination, lien, alignement) sont facultatifs.

 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;

Il est logique de créer des fonctions extract_image_src , extract_caption et extract_link car leurs expressions régulières seront utilisées à maintes reprises pour plusieurs blocs. Veuillez noter qu'une légende dans Gutenberg peut contenir des liens ( <a href="..."> ), cependant, lors de l'appel strip_html_tags , ceux-ci sont supprimés de la légende.

Bien que regrettable, je trouve cette pratique incontournable, car nous ne pouvons pas garantir un lien pour travailler sur des plateformes non Web. Par conséquent, même si le contenu gagne en universalité puisqu'il peut être utilisé pour différents supports, il perd également en spécificité, de sorte que sa qualité est inférieure à celle du contenu créé et personnalisé pour la plate-forme particulière.

 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'

Récupérez simplement l'URL de la vidéo à partir des attributs du bloc et extrayez sa légende du contenu HTML, s'il existe.

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

'core/heading'

La taille de l'en-tête (h1, h2, …, h6) et le texte de l'en-tête ne sont pas des attributs, ils doivent donc être obtenus à partir du contenu HTML. Veuillez noter qu'au lieu de renvoyer la balise HTML pour l'en-tête, l'attribut size est simplement une représentation équivalente, qui est plus agnostique et plus logique pour les plates-formes non 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'

Malheureusement, pour la galerie d'images, je n'ai pas pu extraire les légendes de chaque image, car ce ne sont pas des attributs, et les extraire via une simple expression régulière peut échouer : s'il y a une légende pour les premier et troisième éléments, mais aucune pour la seconde, alors je ne saurais pas quelle légende correspond à quelle image (et je n'ai pas consacré le temps de créer une regex complexe). De même, dans la logique ci-dessous, je récupère toujours la taille d'image "full" , cependant, cela ne doit pas nécessairement être le cas, et je ne sais pas comment la taille la plus appropriée peut être déduite.

 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'

Transformez simplement les éléments <li> en un tableau d'éléments.

 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'

Obtenez l'URL du fichier multimédia téléchargé correspondant.

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

'core/file'

Alors que l'URL du fichier est un attribut, son texte doit être extrait du contenu interne.

 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'

Obtenez l'URL de la vidéo et toutes les propriétés pour configurer la lecture de la vidéo via une expression régulière. Si Gutenberg change un jour l'ordre dans lequel ces propriétés sont imprimées dans le code, cette expression régulière cessera de fonctionner, mettant en évidence l'un des problèmes de ne pas ajouter de métadonnées directement via les attributs de bloc.

 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.

DU REPOS

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.

De plus, à l'instar des arguments GraphQL, notre requête peut être personnalisée via des arguments de champ, permettant d'obtenir uniquement les données qui ont du sens pour une plate-forme spécifique. Par exemple, si nous souhaitons extraire toutes les vidéos Youtube ajoutées à tous les messages, nous pouvons ajouter un modificateur (blockname:core-embed/youtube) au champ block-metadata dans l'URL du point de terminaison, comme dans ce lien. Ou si nous voulons extraire toutes les images d'un article spécifique, nous pouvons ajouter un modificateur (blockname:core/image) comme dans cet autre lien | id | titre).

Conclusion

La stratégie COPE ("Create Once, Publish Everywhere") nous aide à réduire le travail nécessaire pour créer plusieurs applications qui doivent fonctionner sur différents supports (web, e-mail, applications, assistants à domicile, réalité virtuelle, etc.) en créant une source unique de vérité pour notre contenu. Concernant WordPress, même s'il a toujours brillé en tant que système de gestion de contenu, la mise en œuvre de la stratégie COPE s'est historiquement avérée être un défi.

Cependant, quelques développements récents ont rendu de plus en plus possible la mise en œuvre de cette stratégie pour WordPress. D'un côté, depuis l'intégration au cœur de l'API WP REST, et plus nettement depuis le lancement de Gutenberg, la plupart des contenus WordPress sont accessibles via des API, ce qui en fait un véritable système sans tête. De l'autre côté, Gutenberg (qui est le nouvel éditeur de contenu par défaut) est basé sur des blocs, ce qui rend toutes les métadonnées d'un article de blog facilement accessibles aux API.

Par conséquent, la mise en œuvre de COPE pour WordPress est simple. Dans cet article, nous avons vu comment le faire, et tout le code pertinent a été mis à disposition via plusieurs référentiels. Même si la solution n'est pas optimale (car elle implique beaucoup d'analyse de code HTML), elle fonctionne toujours assez bien, avec pour conséquence que l'effort nécessaire pour publier nos applications sur plusieurs plates-formes peut être considérablement réduit. Félicitations à cela !