"Crea una volta, pubblica ovunque" con WordPress

Pubblicato: 2022-03-10
Riepilogo rapido ↬ Il termine COPE ("Crea una volta, pubblica ovunque") è una metodologia per pubblicare i nostri contenuti su diversi output (sito Web, sito AMP, e-mail, app e così via) avendo un'unica fonte di verità per tutti loro . Esploriamo come implementare COPE utilizzando WordPress.

COPE è una strategia per ridurre la quantità di lavoro necessaria per pubblicare i nostri contenuti su diversi mezzi, come sito Web, e-mail, app e altri. Lanciato per la prima volta da NPR, raggiunge il suo obiettivo stabilendo un'unica fonte di verità per i contenuti che può essere utilizzata per tutti i diversi mezzi.

Avere contenuti che funzionano ovunque non è un compito banale poiché ogni mezzo avrà i suoi requisiti. Ad esempio, mentre l'HTML è valido per la stampa di contenuti per il Web, questo linguaggio non è valido per un'app iOS/Android. Allo stesso modo, possiamo aggiungere classi al nostro HTML per il Web, ma queste devono essere convertite in stili per la posta elettronica.

La soluzione a questo enigma è separare la forma dal contenuto: la presentazione e il significato del contenuto devono essere disaccoppiati e solo il significato è usato come unica fonte di verità. La presentazione può quindi essere aggiunta in un altro livello (specifico per il supporto selezionato).

Ad esempio, dato il seguente pezzo di codice HTML, <p> è un tag HTML che si applica principalmente al web e l'attributo class="align-center" è la presentazione (posizionare un elemento "al centro" ha senso per un supporto basato su schermo, ma non per uno basato su audio come Amazon Alexa):

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

Pertanto, questo contenuto non può essere utilizzato come un'unica fonte di verità e deve essere convertito in un formato che separi il significato dalla presentazione, come il seguente codice JSON:

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

Questo pezzo di codice può essere utilizzato come un'unica fonte di verità per il contenuto poiché da esso possiamo ricreare ancora una volta il codice HTML da utilizzare per il web e procurarci un formato appropriato per altri mezzi.

Altro dopo il salto! Continua a leggere sotto ↓

Perché WordPress

WordPress è l'ideale per implementare la strategia COPE per diversi motivi:

  • È versatile.
    Il modello di database di WordPress non definisce un modello di contenuto fisso e rigido; al contrario, è stato creato per versatilità, consentendo di creare vari modelli di contenuto attraverso l'uso di meta campo, che consentono la memorizzazione di dati aggiuntivi per quattro diverse entità: post e tipi di post personalizzati, utenti, commenti e tassonomie ( tag e categorie).
  • È potente.
    WordPress brilla come CMS (Content Management System) e il suo ecosistema di plugin consente di aggiungere facilmente nuove funzionalità.
  • È diffuso.
    Si stima che 1/3 dei siti Web venga eseguito su WordPress. Quindi, una notevole quantità di persone che lavorano sul web conoscono e sono in grado di utilizzare, ad esempio WordPress. Non solo sviluppatori ma anche blogger, venditori, addetti al marketing e così via. Quindi, molte parti interessate diverse, indipendentemente dal loro background tecnico, saranno in grado di produrre il contenuto che funge da unica fonte di verità.
  • È senza testa.
    L'assenza di testa è la capacità di disaccoppiare il contenuto dal livello di presentazione ed è una caratteristica fondamentale per l'implementazione di COPE (come essere in grado di fornire dati a supporti dissimili).

    Da quando ha incorporato l'API REST di WP nel core a partire dalla versione 4.7, e in modo più marcato dal lancio di Gutenberg nella versione 5.0 (per la quale è stato necessario implementare molti endpoint API REST), WordPress può essere considerato un CMS senza testa, poiché la maggior parte dei contenuti di WordPress è possibile accedere tramite un'API REST da qualsiasi applicazione costruita su qualsiasi stack.

    Inoltre, WPGraphQL, di recente creazione, integra WordPress e GraphQL, consentendo di inserire contenuti da WordPress in qualsiasi applicazione utilizzando questa API sempre più popolare. Infine, il mio progetto PoP ha recentemente aggiunto un'implementazione di un'API per WordPress che consente di esportare i dati di WordPress come formati nativi REST, GraphQL o PoP.
  • Ha Gutenberg , un editor basato su blocchi che aiuta notevolmente l'implementazione di COPE perché si basa sul concetto di blocchi (come spiegato nelle sezioni seguenti).

Blob contro blocchi per rappresentare informazioni

Un BLOB è una singola unità di informazioni memorizzate tutte insieme nel database. Ad esempio, la scrittura del post del blog di seguito su un CMS che si basa sui BLOB per archiviare le informazioni memorizzerà il contenuto del post del blog su una singola voce del database, contenente lo stesso contenuto:

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

Come si può apprezzare, le informazioni importanti di questo post del blog (come il contenuto del paragrafo e l'URL, le dimensioni e gli attributi del video di Youtube) non sono facilmente accessibili: se vogliamo recuperarne qualcuna da soli, dobbiamo analizzare il codice HTML per estrarli, il che è tutt'altro che una soluzione ideale.

I blocchi agiscono in modo diverso. Rappresentando le informazioni come un elenco di blocchi, possiamo memorizzare il contenuto in un modo più semantico e accessibile. Ogni blocco veicola il proprio contenuto e le proprie proprietà che possono dipendere dalla sua tipologia (es. è forse un paragrafo o un video?).

Ad esempio, il codice HTML sopra può essere rappresentato come un elenco di blocchi come questo:

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

Attraverso questo modo di rappresentare le informazioni, possiamo facilmente utilizzare qualsiasi dato da solo e adattarlo al supporto specifico su cui deve essere visualizzato. Ad esempio, se vogliamo estrarre tutti i video dal post del blog per mostrarli su un sistema di intrattenimento per auto, possiamo semplicemente scorrere tutti i blocchi di informazioni, selezionare quelli con type="embed" e provider="Youtube" ed estrarre il URL da loro. Allo stesso modo, se vogliamo mostrare il video su un Apple Watch, non dobbiamo preoccuparci delle dimensioni del video, quindi possiamo ignorare gli attributi width e height in modo semplice.

Come Gutenberg implementa i blocchi

Prima della versione 5.0 di WordPress, WordPress utilizzava i BLOB per archiviare i contenuti dei post nel database. A partire dalla versione 5.0, WordPress viene fornito con Gutenberg, un editor basato su blocchi, che consente il modo migliorato di elaborare i contenuti sopra menzionato, che rappresenta una svolta verso l'implementazione di COPE. Sfortunatamente, Gutenberg non è stato progettato per questo caso d'uso specifico e la sua rappresentazione delle informazioni è diversa da quella appena descritta per i blocchi, con conseguenti diversi inconvenienti che dovremo affrontare.

Diamo prima un'occhiata a come il post del blog descritto sopra viene salvato tramite 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 -->

Da questo pezzo di codice, possiamo fare le seguenti osservazioni:

I blocchi vengono salvati tutti insieme nella stessa voce di database

Ci sono due blocchi nel codice sopra:

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

Ad eccezione dei blocchi globali (detti anche “riutilizzabili”), che hanno una propria voce nel database e possono essere referenziati direttamente attraverso i loro ID, tutti i blocchi vengono salvati insieme nella voce del post del blog nella tabella wp_posts .

Quindi, per recuperare le informazioni per un blocco specifico, dovremo prima analizzare il contenuto e isolare tutti i blocchi l'uno dall'altro. Convenientemente, WordPress fornisce la funzione parse_blocks($content) per fare proprio questo. Questa funzione riceve una stringa contenente il contenuto del post del blog (in formato HTML) e restituisce un oggetto JSON contenente i dati per tutti i blocchi contenuti.

Il tipo di blocco e gli attributi vengono trasmessi tramite commenti HTML

Ogni blocco è delimitato da un tag iniziale <!-- wp:{block-type} {block-attributes-encoded-as-JSON} --> e un tag finale <!-- /wp:{block-type} --> che (essendo commenti HTML) assicurano che queste informazioni non siano visibili quando vengono visualizzate su un sito web. Tuttavia, non possiamo visualizzare il post del blog direttamente su un altro supporto, poiché il commento HTML potrebbe essere visibile e apparire come contenuto confuso. Questo non è un grosso problema, poiché dopo aver analizzato il contenuto tramite la funzione parse_blocks($content) , i commenti HTML vengono rimossi e possiamo operare direttamente con i dati del blocco come un oggetto JSON.

I blocchi contengono HTML

Il blocco di paragrafo ha "<p>Look at this wonderful tango:</p>" come contenuto, invece di "Look at this wonderful tango:" . Pertanto, contiene codice HTML (tag <p> e </p> ) che non è utile per altri mezzi e come tale deve essere rimosso, ad esempio tramite la funzione PHP strip_tags($content) .

Quando rimuoviamo i tag, possiamo mantenere quei tag HTML che trasmettono esplicitamente informazioni semantiche, come i tag <strong> e <em> (invece delle loro controparti <b> e <i> che si applicano solo a un supporto basato su schermo), e rimuovi tutti gli altri tag. Questo perché c'è una grande possibilità che i tag semantici possano essere interpretati correttamente anche per altri mezzi (ad es. Amazon Alexa può riconoscere i tag <strong> e <em> e cambiare la sua voce e intonazione di conseguenza durante la lettura di un pezzo di testo). Per fare ciò, invochiamo la funzione strip_tags con un secondo parametro contenente i tag consentiti e lo posizioniamo all'interno di una funzione di wrapping per comodità:

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

La didascalia del video viene salvata all'interno dell'HTML e non come attributo

Come si può vedere nel blocco video di Youtube, la didascalia "An exquisite tango performance" è memorizzata all'interno del codice HTML (racchiuso dal tag <figcaption /> ) ma non all'interno dell'oggetto attributi con codifica JSON. Di conseguenza, per estrarre la didascalia, dovremo analizzare il contenuto del blocco, ad esempio tramite un'espressione regolare:

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

Questo è un ostacolo che dobbiamo superare per estrarre tutti i metadati da un blocco Gutenberg. Questo accade su più blocchi; poiché non tutti i frammenti di metadati vengono salvati come attributi, dobbiamo prima identificare quali sono questi frammenti di metadati, quindi analizzare il contenuto HTML per estrarli blocco per blocco e pezzo per pezzo.

Per quanto riguarda COPE, questa rappresenta un'occasione sprecata per avere una soluzione davvero ottimale. Si potrebbe obiettare che nemmeno l'opzione alternativa è l'ideale, poiché duplierebbe le informazioni, memorizzandole sia all'interno dell'HTML che come attributo, il che viola il principio DRY ( D on't Repeat Yourself). Tuttavia, questa violazione si verifica già: ad esempio, l'attributo className contiene il valore "wp-embed-aspect-16-9 wp-has-aspect-ratio" , che viene stampato anche all'interno del contenuto, in HTML attribute class .

Aggiunta di contenuti tramite Gutenberg
Aggiunta di contenuti tramite Gutenberg (anteprima grande)

Implementazione di COPE

Nota: ho rilasciato questa funzionalità, incluso tutto il codice descritto di seguito, come plug-in WordPress Block Metadata. Puoi installarlo e giocarci in modo da poter avere un assaggio della potenza di COPE. Il codice sorgente è disponibile in questo repository GitHub.

Ora che sappiamo come appare la rappresentazione interna di un blocco, procediamo all'implementazione di COPE tramite Gutenberg. La procedura prevede i seguenti passaggi:

  1. Poiché la funzione parse_blocks($content) restituisce un oggetto JSON con livelli nidificati, dobbiamo prima semplificare questa struttura.
  2. Iteriamo tutti i blocchi e, per ciascuno, identifichiamo i loro pezzi di metadati e li estraiamo, trasformandoli in un formato agnostico nel processo. Gli attributi aggiunti alla risposta possono variare a seconda del tipo di blocco.
  3. Infine, rendiamo disponibili i dati tramite un'API (REST/GraphQL/PoP).

Attuiamo questi passaggi uno per uno.

1. Semplificazione della struttura dell'oggetto JSON

L'oggetto JSON restituito dalla funzione parse_blocks($content) ha un'architettura nidificata, in cui i dati per i blocchi normali vengono visualizzati al primo livello, ma mancano i dati per un blocco riutilizzabile di riferimento (vengono aggiunti solo i dati per il blocco di riferimento), e i dati per i blocchi nidificati (che vengono aggiunti all'interno di altri blocchi) e per i blocchi raggruppati (in cui più blocchi possono essere raggruppati insieme) appaiono sotto 1 o più sottolivelli. Questa architettura rende difficile elaborare i dati dei blocchi da tutti i blocchi nel contenuto del post, poiché da un lato mancano alcuni dati e dall'altro non sappiamo a priori sotto quanti livelli si trovano i dati. Inoltre, c'è un divisore di blocchi posizionato su ogni coppia di blocchi, senza contenuto, che può essere tranquillamente ignorato.

Ad esempio, la risposta ottenuta da un post contenente un blocco semplice, un blocco globale, un blocco annidato contenente un blocco semplice e un gruppo di blocchi semplici, in quest'ordine, è la seguente:

 [ // 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 soluzione migliore è avere tutti i dati al primo livello, quindi la logica per scorrere tutti i dati del blocco è notevolmente semplificata. Quindi, dobbiamo recuperare i dati per questi blocchi riutilizzabili/nidificati/raggruppati e aggiungerli anche al primo livello. Come si può vedere nel codice JSON sopra:

  • Il blocco divisore vuoto ha l'attributo "blockName" con valore NULL
  • Il riferimento a un blocco riutilizzabile è definito tramite $block["attrs"]["ref"]
  • I blocchi nidificati e di gruppo definiscono i blocchi contenuti in $block["innerBlocks"]

Quindi, il seguente codice PHP rimuove i blocchi divisori vuoti, identifica i blocchi riutilizzabili/nidificati/raggruppati e aggiunge i loro dati al primo livello e rimuove tutti i dati da tutti i sottolivelli:

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

Chiamando la funzione get_block_data($content) passando il contenuto del post ( $post->post_content ) come parametro, otteniamo ora la seguente risposta:

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

Anche se non strettamente necessario, è molto utile creare un endpoint API REST per generare il risultato della nostra nuova funzione get_block_data($content) , che ci permetterà di capire facilmente quali blocchi sono contenuti in un post specifico e come sono strutturato. Il codice seguente aggiunge tale endpoint in /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; }

Per vederlo in azione, controlla questo link che esporta i dati per questo post.

2. Estrazione di tutti i metadati dei blocchi in un formato agnostico medio

In questa fase, abbiamo dati di blocco contenenti codice HTML che non è appropriato per COPE. Quindi, dobbiamo rimuovere i tag HTML non semantici per ogni blocco per convertirlo in un formato medio-agnostic.

Possiamo decidere quali sono gli attributi che devono essere estratti in base al tipo di blocco per tipo di blocco (ad esempio, estrarre la proprietà di allineamento del testo per i blocchi "paragraph" , la proprietà dell'URL del video per il blocco "youtube embed" e così via) .

Come abbiamo visto in precedenza, non tutti gli attributi vengono effettivamente salvati come attributi del blocco ma all'interno del contenuto interno del blocco, quindi, per queste situazioni, dovremo analizzare il contenuto HTML utilizzando espressioni regolari per estrarre quei pezzi di metadati.

Dopo aver ispezionato tutti i blocchi spediti tramite il core di WordPress, ho deciso di non estrarre i metadati per i seguenti:

"core/columns"
"core/column"
"core/cover"
Questi si applicano solo ai supporti basati su schermo e (essendo blocchi nidificati) sono difficili da gestire.
"core/html" Questo ha senso solo per il web.
"core/table"
"core/button"
"core/media-text"
Non avevo idea di come rappresentare i loro dati in modo medio-agnostico o se avesse senso.

Questo mi lascia con i seguenti blocchi, per i quali procederò ad estrarre i loro metadati:

  • 'core/paragraph'
  • 'core/image'
  • 'core-embed/youtube' (come rappresentante di tutti i blocchi 'core-embed' )
  • 'core/heading'
  • 'core/gallery'
  • 'core/list'
  • 'core/audio'
  • 'core/file'
  • 'core/video'
  • 'core/code'
  • 'core/preformatted'
  • 'core/quote' & 'core/pullquote'
  • 'core/verse'

Per estrarre i metadati, creiamo la funzione get_block_metadata($block_data) che riceve un array con i dati del blocco per ogni blocco (cioè l'output della nostra funzione precedentemente implementata get_block_data ) e, a seconda del tipo di blocco (fornito nella proprietà "blockName" ), decide quali attributi sono richiesti e come estrarli:

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

Procediamo ad estrarre i metadati per ogni tipo di blocco, uno per uno:

“core/paragraph”

Rimuovi semplicemente i tag HTML dal contenuto e rimuovi le linee di discontinuità finali.

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

'core/image'

Il blocco ha un ID che si riferisce a un file multimediale caricato o, in caso contrario, l'origine dell'immagine deve essere estratta da <img src="..."> . Diversi attributi (didascalia, linkDestination, link, allineamento) sono facoltativi.

 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;

Ha senso creare funzioni extract_image_src , extract_caption ed extract_link poiché le loro espressioni regolari verranno utilizzate più e più volte per diversi blocchi. Tieni presente che una didascalia in Gutenberg può contenere collegamenti ( <a href="..."> ), tuttavia, quando si chiama strip_html_tags , questi vengono rimossi dalla didascalia.

Anche se deplorevole, trovo questa pratica inevitabile, dal momento che non possiamo garantire un collegamento per funzionare su piattaforme non web. Quindi, anche se il contenuto sta guadagnando universalità poiché può essere utilizzato per diversi mezzi, sta anche perdendo specificità, quindi la sua qualità è inferiore rispetto ai contenuti creati e personalizzati per la particolare piattaforma.

 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'

Recupera semplicemente l'URL del video dagli attributi del blocco ed estrai la sua didascalia dal contenuto HTML, se esiste.

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

'core/heading'

Sia la dimensione dell'intestazione (h1, h2, …, h6) che il testo dell'intestazione non sono attributi, quindi devono essere ottenuti dal contenuto HTML. Si noti che, invece di restituire il tag HTML per l'intestazione, l'attributo size è semplicemente una rappresentazione equivalente, che è più agnostica e ha più senso per le piattaforme 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'

Purtroppo per la galleria immagini non sono stato in grado di estrarre le didascalie da ogni immagine, poiché queste non sono attributi, ed estrarle tramite una semplice espressione regolare può fallire: se c'è una didascalia per il primo e il terzo elemento, ma nessuna per il secondo, quindi non saprei quale didascalia corrisponde a quale immagine (e non ho dedicato il tempo a creare una regex complessa). Allo stesso modo, nella logica seguente, recupero sempre la dimensione dell'immagine "full" , tuttavia, questo non deve essere il caso e non sono a conoscenza di come si possa dedurre la dimensione più appropriata.

 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'

Basta trasformare gli elementi <li> in un array di elementi.

 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'

Ottieni l'URL del file multimediale caricato corrispondente.

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

'core/file'

Mentre l'URL del file è un attributo, il suo testo deve essere estratto dal contenuto 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'

Ottieni l'URL del video e tutte le proprietà per configurare la modalità di riproduzione del video tramite un'espressione regolare. Se Gutenberg cambia mai l'ordine in cui queste proprietà vengono stampate nel codice, questa espressione regolare smetterà di funzionare, evidenziando uno dei problemi di non aggiungere metadati direttamente tramite gli attributi del blocco.

 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.

RIPOSO

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.

Inoltre, analogamente agli argomenti di GraphQL, la nostra query può essere personalizzata tramite argomenti di campo, consentendo di ottenere solo i dati che hanno senso per una piattaforma specifica. Ad esempio, se desideriamo estrarre tutti i video di Youtube aggiunti a tutti i post, possiamo aggiungere il modificatore (blockname:core-embed/youtube) per inserire i block-metadata nell'URL dell'endpoint, come in questo link. Oppure, se vogliamo estrarre tutte le immagini da un post specifico, possiamo aggiungere un modificatore (blockname:core/image) come in questo altro link|id|title).

Conclusione

La strategia COPE ("Create Once, Publish Everywhere") ci aiuta a ridurre la quantità di lavoro necessaria per creare diverse applicazioni che devono essere eseguite su supporti diversi (web, e-mail, app, assistenti domestici, realtà virtuale, ecc.) creando un'unica fonte di verità per il nostro contenuto. Per quanto riguarda WordPress, anche se ha sempre brillato come sistema di gestione dei contenuti, l'implementazione della strategia COPE si è storicamente rivelata una sfida.

Tuttavia, un paio di recenti sviluppi hanno reso sempre più fattibile implementare questa strategia per WordPress. Da un lato, dall'integrazione nel core dell'API REST di WP, e in modo più marcato dal lancio di Gutenberg, la maggior parte dei contenuti di WordPress è accessibile tramite API, rendendolo un vero sistema headless. D'altra parte, Gutenberg (che è il nuovo editor di contenuti predefinito) è basato su blocchi, rendendo tutti i metadati all'interno di un post del blog facilmente accessibili alle API.

Di conseguenza, l'implementazione di COPE per WordPress è semplice. In questo articolo abbiamo visto come farlo e tutto il codice rilevante è stato reso disponibile attraverso diversi repository. Anche se la soluzione non è ottimale (poiché comporta un'ampia analisi del codice HTML), funziona comunque abbastanza bene, con la conseguenza che lo sforzo necessario per rilasciare le nostre applicazioni su più piattaforme può essere notevolmente ridotto. Complimenti per quello!