„Creați o dată, publicați peste tot” cu WordPress

Publicat: 2022-03-10
Rezumat rapid ↬ Termenul COPE („Create Once, Publish Everywhere”) este o metodologie de publicare a conținutului nostru în diferite rezultate (site web, site AMP, e-mail, aplicații și așa mai departe), având o singură sursă de adevăr pentru toate acestea. . Să explorăm cum să implementăm COPE folosind WordPress.

COPE este o strategie pentru reducerea volumului de muncă necesară pentru a ne publica conținutul în diferite medii, cum ar fi site-ul web, e-mailul, aplicațiile și altele. Inițiat pentru prima dată de NPR, își îndeplinește scopul prin stabilirea unei singure surse de adevăr pentru conținut care poate fi folosit pentru toate mediile diferite.

A avea un conținut care funcționează peste tot nu este o sarcină banală, deoarece fiecare mediu va avea propriile cerințe. De exemplu, în timp ce HTML este valid pentru imprimarea conținutului pentru web, acest limbaj nu este valabil pentru o aplicație iOS/Android. În mod similar, putem adăuga clase la HTML-ul nostru pentru web, dar acestea trebuie convertite în stiluri pentru e-mail.

Soluția la această enigmă este separarea formei de conținut: prezentarea și sensul conținutului trebuie să fie decuplate și doar sensul este folosit ca sursă unică a adevărului. Prezentarea poate fi apoi adăugată într-un alt strat (specific mediului selectat).

De exemplu, având în vedere următoarea bucată de cod HTML, <p> este o etichetă HTML care se aplică în principal pentru web, iar atributul class="align-center" este prezentarea (plasarea unui element „în centru” are sens pentru un mediu bazat pe ecran, dar nu pentru unul bazat pe audio, cum ar fi Amazon Alexa):

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

Prin urmare, această bucată de conținut nu poate fi folosită ca o singură sursă de adevăr și trebuie convertită într-un format care separă sensul de prezentare, cum ar fi următoarea bucată de cod JSON:

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

Această bucată de cod poate fi folosită ca o singură sursă de adevăr pentru conținut, deoarece din ea putem recrea încă o dată codul HTML pe care să îl folosim pentru web și să obținem un format adecvat pentru alte medii.

Mai multe după săritură! Continuați să citiți mai jos ↓

De ce WordPress

WordPress este ideal pentru implementarea strategiei COPE din mai multe motive:

  • Este versatil.
    Modelul bazei de date WordPress nu definește un model de conținut fix, rigid; dimpotrivă, a fost creat pentru versatilitate, permițând crearea de modele de conținut variate prin utilizarea meta câmpului, care permit stocarea de date suplimentare pentru patru entități diferite: postări și tipuri de postări personalizate, utilizatori, comentarii și taxonomii ( etichete și categorii).
  • Este puternic.
    WordPress strălucește ca un CMS (sistem de management al conținutului), iar ecosistemul său de pluginuri permite adăugarea cu ușurință de noi funcționalități.
  • Este răspândită.
    Se estimează că 1/3 din site-urile web rulează pe WordPress. Apoi, un număr considerabil de oameni care lucrează pe web cunosc și sunt capabili să folosească, adică WordPress. Nu doar dezvoltatori, ci și bloggeri, vânzători, personal de marketing și așa mai departe. Apoi, multe părți interesate diferite, indiferent de experiența lor tehnică, vor putea produce conținutul care acționează ca sursa unică a adevărului.
  • Este fără cap.
    Headlessness este abilitatea de a decupla conținutul de stratul de prezentare și este o caracteristică fundamentală pentru implementarea COPE (de exemplu, pentru a putea alimenta date pe medii diferite).

    De la încorporarea API-ului WP REST în nucleu începând cu versiunea 4.7 și, mai mult, de la lansarea lui Gutenberg în versiunea 5.0 (pentru care au trebuit implementate o mulțime de puncte finale API REST), WordPress poate fi considerat un CMS fără cap, deoarece majoritatea conținutului WordPress poate fi accesat printr-un API REST de orice aplicație construită pe orice stivă.

    În plus, WPGraphQL recent creat integrează WordPress și GraphQL, permițând alimentarea conținutului din WordPress în orice aplicație folosind acest API din ce în ce mai popular. În cele din urmă, propriul meu proiect PoP a adăugat recent o implementare a unui API pentru WordPress care permite exportul datelor WordPress fie în formate REST, GraphQL sau PoP native.
  • Are Gutenberg , un editor bazat pe blocuri care ajută foarte mult la implementarea COPE, deoarece se bazează pe conceptul de blocuri (după cum este explicat în secțiunile de mai jos).

Blob-uri versus blocuri pentru a reprezenta informații

Un blob este o singură unitate de informații stocate împreună în baza de date. De exemplu, scrierea postării de blog de mai jos pe un CMS care se bazează pe blob-uri pentru a stoca informații va stoca conținutul postării de blog într-o singură intrare în baza de date - care conține același conținut:

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

După cum se poate aprecia, informațiile importante din această postare pe blog (cum ar fi conținutul din paragraf și URL-ul, dimensiunile și atributele videoclipului de pe Youtube) nu sunt ușor accesibile: dacă dorim să recuperăm vreuna dintre ele pe cont propriu, trebuie să analizăm codul HTML pentru a le extrage - ceea ce este departe de a fi o soluție ideală.

Blocurile acționează diferit. Reprezentând informația ca o listă de blocuri, putem stoca conținutul într-un mod mai semantic și mai accesibil. Fiecare bloc transmite propriul său conținut și propriile sale proprietăți care pot depinde de tipul său (de ex. este poate un paragraf sau un videoclip?).

De exemplu, codul HTML de mai sus ar putea fi reprezentat ca o listă de blocuri ca aceasta:

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

Prin acest mod de reprezentare a informațiilor, putem folosi cu ușurință orice date pe cont propriu și o putem adapta pentru mediul specific în care trebuie afișată. De exemplu, dacă dorim să extragem toate videoclipurile din postarea de blog pentru a le afișa pe un sistem de divertisment auto, putem pur și simplu să repetam toate blocurile de informații, să le selectăm pe cele cu type="embed" și provider="Youtube" și să extragem URL de la ei. În mod similar, dacă vrem să arătăm videoclipul pe un Apple Watch, nu trebuie să ne pese de dimensiunile videoclipului, așa că putem ignora atributele width și height într-un mod simplu.

Cum implementează Gutenberg blocurile

Înainte de versiunea 5.0 a WordPress, WordPress folosea blob-uri pentru a stoca conținut postat în baza de date. Începând cu versiunea 5.0, WordPress este livrat cu Gutenberg, un editor bazat pe blocuri, care permite modalitatea îmbunătățită de procesare a conținutului menționat mai sus, ceea ce reprezintă o descoperire în implementarea COPE. Din păcate, Gutenberg nu a fost conceput pentru acest caz de utilizare specific, iar reprezentarea sa a informațiilor este diferită de cea descrisă tocmai pentru blocuri, rezultând mai multe neplăceri cu care va trebui să ne confruntăm.

Să vedem mai întâi cum este salvată postarea de blog descrisă mai sus prin 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 -->

Din această bucată de cod, putem face următoarele observații:

Blocurile sunt salvate toate împreună în aceeași intrare în baza de date

Există două blocuri în codul de mai sus:

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

Cu excepția blocurilor globale (numite și „reutilizabile”), care au o intrare proprie în baza de date și pot fi referite direct prin ID-urile lor, toate blocurile sunt salvate împreună în intrarea postării de blog în tabelul wp_posts .

Prin urmare, pentru a prelua informațiile pentru un anumit bloc, va trebui mai întâi să analizăm conținutul și să izolăm toate blocurile unul de celălalt. În mod convenabil, WordPress oferă funcția parse_blocks($content) pentru a face exact acest lucru. Această funcție primește un șir care conține conținutul postării de blog (în format HTML) și returnează un obiect JSON care conține datele pentru toate blocurile conținute.

Tipul de bloc și atributele sunt transmise prin comentarii HTML

Fiecare bloc este delimitat cu o etichetă de început <!-- wp:{block-type} {block-attributes-encoded-as-JSON} --> și o etichetă de sfârșit <!-- /wp:{block-type} --> care (fiind comentarii HTML) asigură că aceste informații nu vor fi vizibile atunci când sunt afișate pe un site web. Cu toate acestea, nu putem afișa postarea de blog direct pe alt mediu, deoarece comentariul HTML poate fi vizibil, apărând ca conținut deformat. Totuși, aceasta nu este mare lucru, deoarece după analizarea conținutului prin funcția parse_blocks($content) , comentariile HTML sunt eliminate și putem opera direct cu datele blocului ca obiect JSON.

Blocurile Conțin HTML

Blocul de paragraf are ca conținut "<p>Look at this wonderful tango:</p>" , în loc de "Look at this wonderful tango:" . Prin urmare, conține cod HTML (etichete <p> și </p> ) care nu este util pentru alte medii și, ca atare, trebuie eliminat, de exemplu prin funcția PHP strip_tags($content) .

La eliminarea etichetelor, putem păstra acele etichete HTML care transmit în mod explicit informații semantice, cum ar fi etichetele <strong> și <em> (în loc de omologii lor <b> și <i> care se aplică doar unui mediu bazat pe ecran) și eliminați toate celelalte etichete. Acest lucru se datorează faptului că există o mare șansă ca etichetele semantice să poată fi interpretate corect și pentru alte medii (de exemplu, Amazon Alexa poate recunoaște etichetele <strong> și <em> și își poate schimba vocea și intonația în consecință atunci când citește o bucată de text). Pentru a face acest lucru, invocăm funcția strip_tags cu un al doilea parametru care conține etichetele permise și o plasăm într-o funcție de împachetare pentru comoditate:

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

Legenda videoclipului este salvată în HTML și nu ca atribut

După cum se poate vedea în blocul video Youtube, legenda "An exquisite tango performance" este stocată în codul HTML (închis de eticheta <figcaption /> ), dar nu în interiorul obiectului atribute codificate JSON. În consecință, pentru a extrage legenda, va trebui să analizăm conținutul blocului, de exemplu printr-o expresie regulată:

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

Acesta este un obstacol pe care trebuie să-l depășim pentru a extrage toate metadatele dintr-un bloc Gutenberg. Acest lucru se întâmplă pe mai multe blocuri; întrucât nu toate metadatele sunt salvate ca atribute, trebuie să identificăm mai întâi care sunt aceste metadate și apoi să analizăm conținutul HTML pentru a le extrage bloc cu bloc și bucată cu bucată.

În ceea ce privește COPE, aceasta reprezintă o șansă irosită de a avea o soluție cu adevărat optimă. S-ar putea argumenta că nici opțiunea alternativă nu este ideală, deoarece ar duplica informațiile, stocându-le atât în ​​HTML, cât și ca atribut, ceea ce încalcă principiul DRY ( D on't Repet Yourself ). Cu toate acestea, această încălcare are deja loc: de exemplu, atributul className conține valoarea "wp-embed-aspect-16-9 wp-has-aspect-ratio" , care este tipărită și în interiorul conținutului, sub HTML attribute class .

Adăugarea de conținut prin Gutenberg
Adăugarea de conținut prin Gutenberg (previzualizare mare)

Implementarea COPE

Notă: am lansat această funcționalitate, inclusiv tot codul descris mai jos, ca plugin WordPress Block Metadata. Ești binevenit să-l instalezi și să te joci cu el, astfel încât să poți gusta din puterea COPE. Codul sursă este disponibil în acest depozit GitHub.

Acum că știm cum arată reprezentarea interioară a unui bloc, să trecem la implementarea COPE prin Gutenberg. Procedura va presupune următorii pași:

  1. Deoarece funcția parse_blocks($content) returnează un obiect JSON cu niveluri imbricate, trebuie mai întâi să simplificăm această structură.
  2. Repetăm ​​toate blocurile și, pentru fiecare, le identificăm bucățile de metadate și le extragem, transformându-le într-un format mediu-agnostic în proces. Ce atribute sunt adăugate răspunsului pot varia în funcție de tipul de bloc.
  3. În sfârșit, facem datele disponibile printr-un API (REST/GraphQL/PoP).

Să implementăm acești pași unul câte unul.

1. Simplificarea structurii obiectului JSON

Obiectul JSON returnat de la funcția parse_blocks($content) are o arhitectură imbricată, în care datele pentru blocurile normale apar la primul nivel, dar lipsesc datele pentru un bloc reutilizabil referit (se adaugă doar datele pentru blocul de referință), iar datele pentru blocurile imbricate (care sunt adăugate în alte blocuri) și pentru blocurile grupate (unde pot fi grupate mai multe blocuri) apar sub 1 sau mai multe subniveluri. Această arhitectură îngreunează procesarea datelor blocului din toate blocurile din conținutul postării, deoarece pe de o parte lipsesc unele date, iar pe de altă parte nu știm a priori sub câte niveluri se află datele. În plus, există un divizor de bloc plasat în fiecare pereche de blocuri, fără conținut, care poate fi ignorat în siguranță.

De exemplu, răspunsul obținut dintr-un post care conține un bloc simplu, un bloc global, un bloc imbricat care conține un bloc simplu și un grup de blocuri simple, în această ordine, este următorul:

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

O soluție mai bună este să aveți toate datele la primul nivel, astfel încât logica de a itera prin toate datele blocului este mult simplificată. Prin urmare, trebuie să obținem datele pentru aceste blocuri reutilizabile/imbricate/grupate și să le adăugam și la primul nivel. După cum se poate vedea în codul JSON de mai sus:

  • Blocul divizor gol are atributul "blockName" cu valoarea NULL
  • Referința la un bloc reutilizabil este definită prin $block["attrs"]["ref"]
  • Blocurile imbricate și de grup își definesc blocurile conținute sub $block["innerBlocks"]

Prin urmare, următorul cod PHP elimină blocurile de separare goale, identifică blocurile reutilizabile/imbricate/grupate și adaugă datele lor la primul nivel și elimină toate datele de la toate subnivelurile:

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

Apelând funcția get_block_data($content) trecând conținutul postării ( $post->post_content ) ca parametru, obținem acum următorul răspuns:

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

Chiar dacă nu este strict necesar, este foarte util să creăm un punct final API REST pentru a scoate rezultatul noii noastre funcții get_block_data($content) , care ne va permite să înțelegem cu ușurință ce blocuri sunt conținute într-o anumită postare și cum sunt acestea. structurat. Codul de mai jos adaugă un astfel de punct final sub /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; }

Pentru a-l vedea în acțiune, consultați acest link care exportă datele pentru această postare.

2. Extragerea tuturor metadatelor blocului într-un format agnostic mediu

În această etapă, avem date bloc care conțin cod HTML care nu este adecvat pentru COPE. Prin urmare, trebuie să eliminăm etichetele HTML non-semantice pentru fiecare bloc pentru a-l converti într-un format mediu-agnostic.

Putem decide care sunt atributele care trebuie extrase pe un tip de bloc după tipul de bloc (de exemplu, extrageți proprietatea de aliniere a textului pentru blocurile "paragraph" , proprietatea URL a videoclipului pentru blocul "youtube embed" și așa mai departe) .

După cum am văzut mai devreme, nu toate atributele sunt de fapt salvate ca atribute de bloc, ci în conținutul interior al blocului, prin urmare, pentru aceste situații, va trebui să analizăm conținutul HTML folosind expresii regulate pentru a extrage acele fragmente de metadate.

După ce am inspectat toate blocurile livrate prin nucleul WordPress, am decis să nu extrag metadate pentru următoarele:

"core/columns"
"core/column"
"core/cover"
Acestea se aplică numai mediilor bazate pe ecran și (fiind blocuri imbricate) sunt dificil de tratat.
"core/html" Acesta are sens doar pentru web.
"core/table"
"core/button"
"core/media-text"
Nu aveam nicio idee cum să le reprezint datele într-o manieră agnostică medie sau dacă are sens.

Acest lucru îmi lasă cu următoarele blocuri, pentru care voi continua să extrag metadatele lor:

  • 'core/paragraph'
  • 'core/image'
  • 'core-embed/youtube' (ca reprezentant al tuturor blocurilor 'core-embed' )
  • 'core/heading'
  • 'core/gallery'
  • 'core/list'
  • 'core/audio'
  • 'core/file'
  • 'core/video'
  • 'core/code'
  • 'core/preformatted'
  • 'core/quote' și 'core/pullquote'
  • 'core/verse'

Pentru a extrage metadatele, creăm funcția get_block_metadata($block_data) care primește o matrice cu datele blocului pentru fiecare bloc (adică rezultatul din funcția noastră implementată anterior get_block_data ) și, în funcție de tipul blocului (furnizat sub proprietatea "blockName" ), decide ce atribute sunt necesare și cum să le extragă:

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

Să continuăm să extragem metadatele pentru fiecare tip de bloc, unul câte unul:

“core/paragraph”

Pur și simplu eliminați etichetele HTML din conținut și eliminați liniile de întrerupere de sfârșit.

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

'core/image'

Blocul fie are un ID care se referă la un fișier media încărcat sau, dacă nu, sursa imaginii trebuie extrasă de sub <img src="..."> . Mai multe atribute (legendă, linkDestinație, link, aliniere) sunt opționale.

 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;

Este logic să creați funcțiile extract_image_src , extract_caption și extract_link , deoarece expresiile lor regulate vor fi folosite în mod repetat pentru mai multe blocuri. Vă rugăm să rețineți că o legendă în Gutenberg poate conține link-uri ( <a href="..."> ), totuși, atunci când apelați strip_html_tags , acestea sunt eliminate din legendă.

Chiar dacă regretabilă, consider această practică inevitabilă, deoarece nu putem garanta un link pentru a lucra în platforme non-web. Prin urmare, chiar dacă conținutul câștigă universalitate, deoarece poate fi folosit pentru diferite medii, își pierde și specificitatea, astfel încât calitatea sa este mai slabă în comparație cu conținutul care a fost creat și personalizat pentru platforma respectivă.

 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'

Pur și simplu, preluați adresa URL a videoclipului din atributele blocului și extrageți legenda sa din conținutul HTML, dacă există.

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

'core/heading'

Atât dimensiunea antetului (h1, h2, …, h6) cât și textul antetului nu sunt atribute, așa că acestea trebuie obținute din conținutul HTML. Vă rugăm să rețineți că, în loc să returneze eticheta HTML pentru antet, atributul size este pur și simplu o reprezentare echivalentă, care este mai agnostică și are mai mult sens pentru platformele 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'

Din păcate, pentru galeria de imagini nu am reușit să extrag legendele din fiecare imagine, deoarece acestea nu sunt atribute, iar extragerea lor printr-o simplă expresie regulată poate eșua: Dacă există o legendă pentru primul și al treilea element, dar nu pentru al doilea, atunci nu aș ști ce legendă corespunde cărei imagini (și nu am dedicat timp pentru a crea un regex complex). La fel, în logica de mai jos, recuperez întotdeauna dimensiunea "full" a imaginii, totuși, acest lucru nu trebuie să fie cazul și nu știu cum poate fi dedusă dimensiunea mai adecvată.

 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'

Pur și simplu transformați elementele <li> într-o serie de elemente.

 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'

Obțineți adresa URL a fișierului media încărcat corespunzător.

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

'core/file'

În timp ce adresa URL a fișierului este un atribut, textul acestuia trebuie extras din conținutul interior.

 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'

Obțineți adresa URL a videoclipului și toate proprietățile pentru a configura modul în care este redat videoclipul printr-o expresie regulată. Dacă Gutenberg schimbă vreodată ordinea în care aceste proprietăți sunt tipărite în cod, atunci această regex nu va mai funcționa, evidențiind una dintre problemele de a nu adăuga metadate direct prin atributele blocului.

 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.

ODIHNĂ

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.

În plus, similar argumentelor GraphQL, interogarea noastră poate fi personalizată prin argumente de câmp, permițând obținerea doar a datelor care au sens pentru o anumită platformă. De exemplu, dacă dorim să extragem toate videoclipurile YouTube adăugate la toate postările, putem adăuga un modificator (blockname:core-embed/youtube) la câmpul block-metadata din URL-ul punctului final, ca în acest link. Sau dacă dorim să extragem toate imaginile dintr-o anumită postare, putem adăuga un modificator (blockname:core/image) ca în acest alt link|id|title).

Concluzie

Strategia COPE („Create Once, Publish Everywhere”) ne ajută să reducem cantitatea de muncă necesară pentru a crea mai multe aplicații care trebuie să ruleze pe diferite medii (web, e-mail, aplicații, asistenți acasă, realitate virtuală etc.) prin crearea unei singure surse de adevăr pentru conținutul nostru. În ceea ce privește WordPress, chiar dacă a strălucit întotdeauna ca sistem de management al conținutului, implementarea strategiei COPE s-a dovedit istoric a fi o provocare.

Cu toate acestea, câteva evoluții recente au făcut din ce în ce mai fezabilă implementarea acestei strategii pentru WordPress. Pe de o parte, de la integrarea în nucleul API-ului WP REST și, mai mult, de la lansarea lui Gutenberg, majoritatea conținutului WordPress este accesibil prin intermediul API-urilor, ceea ce îl face un veritabil sistem fără cap. Pe de altă parte, Gutenberg (care este noul editor de conținut implicit) se bazează pe blocuri, ceea ce face ca toate metadatele dintr-o postare de blog să fie ușor accesibile API-urilor.

În consecință, implementarea COPE pentru WordPress este simplă. În acest articol, am văzut cum să o facem și tot codul relevant a fost pus la dispoziție prin mai multe depozite. Chiar dacă soluția nu este optimă (deoarece implică mult parsarea codului HTML), încă funcționează destul de bine, cu consecința că efortul necesar pentru a lansa aplicațiile noastre pe mai multe platforme poate fi mult redus. Felicitari pentru asta!