„Einmal erstellen, überall veröffentlichen“ mit WordPress
Veröffentlicht: 2022-03-10COPE ist eine Strategie zur Reduzierung des Arbeitsaufwands, der erforderlich ist, um unsere Inhalte in verschiedenen Medien wie Websites, E-Mails, Apps und anderen zu veröffentlichen. Es wurde zuerst von NPR entwickelt und erreicht sein Ziel, indem es eine einzige Quelle der Wahrheit für Inhalte etabliert, die für alle unterschiedlichen Medien verwendet werden kann.
Inhalte zu haben, die überall funktionieren, ist keine triviale Aufgabe, da jedes Medium seine eigenen Anforderungen hat. Während beispielsweise HTML zum Drucken von Inhalten für das Web gültig ist, ist diese Sprache nicht für eine iOS/Android-App gültig. Auf ähnliche Weise können wir unserem HTML für das Web Klassen hinzufügen, aber diese müssen in E-Mail-Stile konvertiert werden.
Die Lösung für dieses Rätsel ist die Trennung von Form und Inhalt: Die Präsentation und die Bedeutung des Inhalts müssen entkoppelt werden, und nur die Bedeutung wird als einzige Quelle der Wahrheit verwendet. Die Präsentation kann dann in einer anderen Ebene (spezifisch für das ausgewählte Medium) hinzugefügt werden.
Zum Beispiel ist im folgenden HTML-Code das <p>
ein HTML-Tag, das hauptsächlich für das Web gilt, und das Attribut class="align-center"
ist die Präsentation (das Platzieren eines Elements "in der Mitte" ist sinnvoll für a bildschirmbasiertes Medium, aber nicht für ein audiobasiertes wie Amazon Alexa):
<p class="align-center">Hello world!</p>
Daher kann dieser Inhalt nicht als Single Source of Truth verwendet werden und muss in ein Format konvertiert werden, das die Bedeutung von der Präsentation trennt, wie z. B. das folgende Stück JSON-Code:
{ content: "Hello world!", placement: "center", type: "paragraph" }
Dieses Stück Code kann als Single Source of Truth für Inhalte verwendet werden, da wir daraus den HTML-Code für das Web erneut erstellen und ein geeignetes Format für andere Medien beschaffen können.
Warum Wordpress
WordPress ist aus mehreren Gründen ideal, um die COPE-Strategie umzusetzen:
- Es ist vielseitig.
Das WordPress-Datenbankmodell definiert kein festes, starres Inhaltsmodell; im Gegenteil, es wurde aus Gründen der Vielseitigkeit entwickelt und ermöglicht die Erstellung verschiedener Inhaltsmodelle durch die Verwendung von Metafeldern, die das Speichern zusätzlicher Daten für vier verschiedene Entitäten ermöglichen: Beiträge und benutzerdefinierte Beitragstypen, Benutzer, Kommentare und Taxonomien ( Tags und Kategorien). - Es ist mächtig.
WordPress glänzt als CMS (Content Management System) und sein Plugin-Ökosystem ermöglicht das einfache Hinzufügen neuer Funktionalitäten. - Es ist weit verbreitet.
Es wird geschätzt, dass 1/3 der Websites auf WordPress laufen. Dann kennt eine beträchtliche Anzahl von Leuten, die im Web arbeiten, WordPress und kann es verwenden. Nicht nur Entwickler, sondern auch Blogger, Verkäufer, Marketingmitarbeiter und so weiter. Dann werden viele verschiedene Interessengruppen, unabhängig von ihrem technischen Hintergrund, in der Lage sein, den Inhalt zu produzieren, der als einzige Quelle der Wahrheit fungiert. - Es ist kopflos.
Headlessness ist die Fähigkeit, den Inhalt von der Präsentationsschicht zu entkoppeln, und es ist ein grundlegendes Merkmal für die Implementierung von COPE (um Daten in unterschiedliche Medien einspeisen zu können).
Seit der Integration der WP-REST-API in den Kern ab Version 4.7 und noch deutlicher seit dem Start von Gutenberg in Version 5.0 (für das viele REST-API-Endpunkte implementiert werden mussten) kann WordPress als Headless-CMS betrachtet werden, da die meisten WordPress-Inhalte kann über eine REST-API von jeder Anwendung aufgerufen werden, die auf einem beliebigen Stack erstellt wurde.
Darüber hinaus integriert das kürzlich erstellte WPGraphQL WordPress und GraphQL, sodass Inhalte aus WordPress mithilfe dieser immer beliebter werdenden API in jede Anwendung eingespeist werden können. Schließlich hat mein eigenes Projekt PoP kürzlich eine Implementierung einer API für WordPress hinzugefügt, die es ermöglicht, die WordPress-Daten entweder als REST-, GraphQL- oder PoP-native Formate zu exportieren. - Es verfügt über Gutenberg , einen blockbasierten Editor, der die Implementierung von COPE erheblich unterstützt, da er auf dem Konzept von Blöcken basiert (wie in den folgenden Abschnitten erläutert).
Blobs im Vergleich zu Blöcken zur Darstellung von Informationen
Ein Blob ist eine einzelne Informationseinheit, die alle zusammen in der Datenbank gespeichert sind. Wenn Sie beispielsweise den folgenden Blog-Beitrag auf einem CMS schreiben, das zum Speichern von Informationen auf Blobs angewiesen ist, wird der Inhalt des Blog-Beitrags in einem einzigen Datenbankeintrag gespeichert, der denselben Inhalt enthält:
<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>
Wie zu erkennen ist, sind die wichtigen Informationen aus diesem Blogbeitrag (wie der Inhalt des Absatzes und die URL, die Abmessungen und Attribute des Youtube-Videos) nicht leicht zugänglich: Wenn wir sie abrufen möchten allein müssen wir den HTML-Code parsen, um sie zu extrahieren – was alles andere als eine ideale Lösung ist.
Blöcke verhalten sich anders. Indem wir die Informationen als Liste von Blöcken darstellen, können wir den Inhalt semantischer und zugänglicher speichern. Jeder Block vermittelt seinen eigenen Inhalt und seine eigenen Eigenschaften, die von seiner Art abhängen können (zB ist es vielleicht ein Absatz oder ein Video?).
Der obige HTML-Code könnte beispielsweise als Liste von Blöcken wie folgt dargestellt werden:
{ [ 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" ] }
Durch diese Art der Darstellung von Informationen können wir problemlos jedes Datenelement für sich verwenden und es für das spezifische Medium anpassen, in dem es angezeigt werden soll. Wenn wir beispielsweise alle Videos aus dem Blogbeitrag extrahieren möchten, um sie auf einem Autounterhaltungssystem anzuzeigen, können wir einfach alle Informationsblöcke iterieren, diejenigen mit type="embed"
und provider="Youtube"
auswählen und extrahieren URL von ihnen. Wenn wir das Video auf einer Apple Watch zeigen möchten, brauchen wir uns nicht um die Abmessungen des Videos zu kümmern, sodass wir die Attribute width
und height
auf einfache Weise ignorieren können.
Wie Gutenberg Blöcke implementiert
Vor der WordPress-Version 5.0 verwendete WordPress Blobs, um Post-Inhalte in der Datenbank zu speichern. Ab Version 5.0 wird WordPress mit Gutenberg ausgeliefert, einem blockbasierten Editor, der die oben erwähnte verbesserte Art der Verarbeitung von Inhalten ermöglicht, was einen Durchbruch in Richtung der Implementierung von COPE darstellt. Leider wurde Gutenberg nicht für diesen speziellen Anwendungsfall entwickelt, und seine Darstellung der Informationen unterscheidet sich von der gerade für Blöcke beschriebenen, was zu mehreren Unannehmlichkeiten führt, mit denen wir uns befassen müssen.
Werfen wir zunächst einen Blick darauf, wie der oben beschriebene Blogbeitrag durch Gutenberg gespeichert wird:
<!-- 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 -->
Aus diesem Codestück können wir die folgenden Beobachtungen machen:
Blöcke werden alle zusammen im selben Datenbankeintrag gespeichert
Es gibt zwei Blöcke im obigen Code:
<!-- 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 -->
Mit Ausnahme globaler (auch „reusable“ genannter) Blöcke, die einen eigenen Eintrag in der Datenbank haben und über ihre ID direkt referenziert werden können, werden alle Blöcke gemeinsam im Eintrag des Blogposts in der Tabelle wp_posts
.
Um die Informationen für einen bestimmten Block abzurufen, müssen wir daher zuerst den Inhalt parsen und alle Blöcke voneinander isolieren. Praktischerweise stellt WordPress die Funktion parse_blocks($content)
, um genau dies zu tun. Diese Funktion empfängt eine Zeichenfolge mit dem Inhalt des Blogbeitrags (im HTML-Format) und gibt ein JSON-Objekt zurück, das die Daten für alle enthaltenen Blöcke enthält.
Blocktyp und Attribute werden durch HTML-Kommentare übermittelt
Jeder Block wird durch ein Start-Tag <!-- wp:{block-type} {block-attributes-encoded-as-JSON} -->
und ein End-Tag <!-- /wp:{block-type} -->
begrenzt. <!-- /wp:{block-type} -->
die (als HTML-Kommentare) sicherstellen, dass diese Informationen nicht sichtbar sind, wenn sie auf einer Website angezeigt werden. Wir können den Blogbeitrag jedoch nicht direkt auf einem anderen Medium anzeigen, da der HTML-Kommentar möglicherweise sichtbar ist und als verstümmelter Inhalt angezeigt wird. Dies ist jedoch keine große Sache, da nach dem Parsen des Inhalts durch die Funktion parse_blocks($content)
die HTML-Kommentare entfernt werden und wir direkt mit den Blockdaten als JSON-Objekt arbeiten können.
Blöcke enthalten HTML
Der Absatzblock hat als Inhalt "<p>Look at this wonderful tango:</p>"
anstelle von "Look at this wonderful tango:"
. Daher enthält es HTML-Code (Tags <p>
und </p>
), der für andere Medien nicht nützlich ist und als solcher entfernt werden muss, beispielsweise durch die PHP-Funktion strip_tags($content)
.
Beim Entfernen von Tags können wir die HTML-Tags beibehalten, die explizit semantische Informationen vermitteln, wie z. B. die Tags <strong>
und <em>
(anstelle ihrer Gegenstücke <b>
und <i>
, die nur für ein bildschirmbasiertes Medium gelten) und alle anderen Tags entfernen. Denn es besteht eine große Chance, dass semantische Tags auch für andere Medien richtig interpretiert werden können (z. B. kann Amazon Alexa die Tags <strong>
und <em>
erkennen und beim Vorlesen eines Textes seine Stimme und Intonation entsprechend ändern). Dazu rufen wir die Funktion strip_tags
mit einem zweiten Parameter auf, der die zulässigen Tags enthält, und platzieren sie der Einfachheit halber in einer Wrapping-Funktion:
function strip_html_tags($content) { return strip_tags($content, '<strong><em>'); }
Die Bildunterschrift des Videos wird im HTML und nicht als Attribut gespeichert
Wie im Youtube-Videoblock zu sehen ist, wird die Beschriftung "An exquisite tango performance"
im HTML-Code gespeichert (umschlossen vom Tag <figcaption />
), aber nicht im JSON-codierten Attribute-Objekt. Um die Beschriftung zu extrahieren, müssen wir daher den Blockinhalt parsen, beispielsweise durch einen regulären Ausdruck:
function extract_caption($content) { $matches = []; preg_match('/<figcaption>(.*?)<\/figcaption>/', $content, $matches); if ($caption = $matches[1]) { return strip_html_tags($caption); } return null; }
Dies ist eine Hürde, die wir überwinden müssen, um alle Metadaten aus einem Gutenberg-Block zu extrahieren. Dies geschieht auf mehreren Blöcken; Da nicht alle Metadaten als Attribute gespeichert werden, müssen wir zunächst identifizieren, um welche Metadaten es sich handelt, und dann den HTML-Inhalt parsen, um sie Block für Block und Stück für Stück zu extrahieren.
In Bezug auf COPE ist dies eine vertane Chance, eine wirklich optimale Lösung zu haben. Man könnte argumentieren, dass die alternative Option auch nicht ideal ist, da sie Informationen duplizieren und sowohl innerhalb des HTML als auch als Attribut speichern würde, was gegen das DRY -Prinzip (Do not Repeat Yourself ) verstößt. Diese Verletzung findet jedoch bereits statt: Beispielsweise enthält das Attribut className
den Wert "wp-embed-aspect-16-9 wp-has-aspect-ratio"
, der auch innerhalb des Inhalts unter dem HTML-Attribut class
gedruckt wird.
Implementierung von COPE
Hinweis: Ich habe diese Funktionalität, einschließlich des gesamten unten beschriebenen Codes, als WordPress-Plugin Block Metadata veröffentlicht. Sie können es gerne installieren und damit spielen, damit Sie einen Vorgeschmack auf die Leistungsfähigkeit von COPE bekommen. Der Quellcode ist in diesem GitHub-Repo verfügbar.
Nachdem wir nun wissen, wie die innere Darstellung eines Blocks aussieht, wollen wir mit der Implementierung von COPE durch Gutenberg fortfahren. Das Verfahren umfasst die folgenden Schritte:
- Da die Funktion
parse_blocks($content)
ein JSON-Objekt mit verschachtelten Ebenen zurückgibt, müssen wir diese Struktur zunächst vereinfachen. - Wir iterieren alle Blöcke und identifizieren für jeden ihre Metadaten und extrahieren sie, um sie dabei in ein medienunabhängiges Format umzuwandeln. Welche Attribute der Antwort hinzugefügt werden, kann je nach Blocktyp variieren.
- Wir stellen die Daten schließlich über eine API (REST/GraphQL/PoP) zur Verfügung.
Lassen Sie uns diese Schritte nacheinander implementieren.
1. Vereinfachen der Struktur des JSON-Objekts
Das von der Funktion parse_blocks($content)
zurückgegebene JSON-Objekt hat eine verschachtelte Architektur, in der die Daten für normale Blöcke auf der ersten Ebene erscheinen, aber die Daten für einen referenzierten wiederverwendbaren Block fehlen (nur Daten für den referenzierenden Block werden hinzugefügt). und die Daten für verschachtelte Blöcke (die innerhalb anderer Blöcke hinzugefügt werden) und für gruppierte Blöcke (wo mehrere Blöcke zusammen gruppiert werden können) erscheinen unter 1 oder mehreren Unterebenen. Diese Architektur macht es schwierig, die Blockdaten von allen Blöcken im Beitragsinhalt zu verarbeiten, da einerseits einige Daten fehlen und andererseits wir a priori nicht wissen, unter wie vielen Ebenen sich Daten befinden. Darüber hinaus gibt es einen Blockteiler, der jedes Blockpaar platziert, das keinen Inhalt enthält, der getrost ignoriert werden kann.
Beispielsweise ist die Antwort, die von einem Beitrag erhalten wird, der einen einfachen Block, einen globalen Block, einen verschachtelten Block mit einem einfachen Block und eine Gruppe von einfachen Blöcken in dieser Reihenfolge enthält, die folgende:
[ // 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" ] } ]
Eine bessere Lösung besteht darin, alle Daten auf der ersten Ebene zu haben, sodass die Logik zum Durchlaufen aller Blockdaten stark vereinfacht wird. Daher müssen wir die Daten für diese wiederverwendbaren/verschachtelten/gruppierten Blöcke abrufen und sie auch auf der ersten Ebene hinzufügen lassen. Wie im obigen JSON-Code zu sehen ist:
- Der leere Trennblock hat das Attribut
"blockName"
mit dem WertNULL
- Die Referenz auf einen wiederverwendbaren Block wird durch
$block["attrs"]["ref"]
definiert - Verschachtelte und Gruppenblöcke definieren ihre enthaltenen Blöcke unter
$block["innerBlocks"]
Daher entfernt der folgende PHP-Code die leeren Trennblöcke, identifiziert die wiederverwendbaren/verschachtelten/gruppierten Blöcke und fügt ihre Daten der ersten Ebene hinzu und entfernt alle Daten aus allen Unterebenen:
/** * 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); }
Wenn wir die Funktion get_block_data($content)
und den Inhalt des Beitrags ( $post->post_content
) als Parameter übergeben, erhalten wir nun die folgende Antwort:
[[ { "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" ] } ]
Auch wenn es nicht unbedingt erforderlich ist, ist es sehr hilfreich, einen REST-API-Endpunkt zu erstellen, um das Ergebnis unserer neuen Funktion get_block_data($content)
, wodurch wir leicht verstehen können, welche Blöcke in einem bestimmten Beitrag enthalten sind und wie sie sind strukturiert. Der folgende Code fügt einen solchen Endpunkt unter /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; }
Um es in Aktion zu sehen, sehen Sie sich diesen Link an, der die Daten für diesen Beitrag exportiert.
2. Extrahieren aller Blockmetadaten in ein medienunabhängiges Format
In diesem Stadium haben wir Blockdaten, die HTML-Code enthalten, der für COPE nicht geeignet ist. Daher müssen wir die nicht-semantischen HTML-Tags für jeden Block entfernen, um ihn in ein medienunabhängiges Format zu konvertieren.
Wir können entscheiden, welche Attribute Blocktyp für Blocktyp extrahiert werden müssen (z. B. extrahieren Sie die Textausrichtungseigenschaft für "paragraph"
-Blöcke, die Video-URL-Eigenschaft für den "youtube embed"
-Block und so weiter). .
Wie wir bereits gesehen haben, werden nicht alle Attribute tatsächlich als Blockattribute gespeichert, sondern innerhalb des inneren Inhalts des Blocks, daher müssen wir für diese Situationen den HTML-Inhalt mit regulären Ausdrücken parsen, um diese Metadaten zu extrahieren.
Nachdem ich alle über den WordPress-Kern versendeten Blöcke überprüft hatte, entschied ich mich, keine Metadaten für die folgenden zu extrahieren:
"core/columns" "core/column" "core/cover" | Diese gelten nur für bildschirmbasierte Medien und sind (als verschachtelte Blöcke) schwierig zu handhaben. |
"core/html" | Dies ist nur für das Web sinnvoll. |
"core/table" "core/button" "core/media-text" | Ich hatte keine Ahnung, wie ich ihre Daten medienunabhängig darstellen sollte oder ob es überhaupt Sinn macht. |
Damit bleiben mir die folgenden Blöcke, für die ich fortfahre, ihre Metadaten zu extrahieren:
-
'core/paragraph'
-
'core/image'
-
'core-embed/youtube'
(stellvertretend für alle'core-embed'
-Blöcke) -
'core/heading'
-
'core/gallery'
-
'core/list'
-
'core/audio'
-
'core/file'
-
'core/video'
-
'core/code'
-
'core/preformatted'
-
'core/quote'
&'core/pullquote'
-
'core/verse'
Um die Metadaten zu extrahieren, erstellen wir die Funktion get_block_metadata($block_data)
, die für jeden Block ein Array mit den Blockdaten empfängt (dh die Ausgabe unserer zuvor implementierten Funktion get_block_data
) und je nach Blocktyp (bereitgestellt unter Eigenschaft "blockName"
), entscheidet, welche Attribute erforderlich sind und wie sie extrahiert werden:
/** * 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; }
Lassen Sie uns fortfahren, die Metadaten für jeden Blocktyp einzeln zu extrahieren:
“core/paragraph”
Entfernen Sie einfach die HTML-Tags aus dem Inhalt und entfernen Sie die abschließenden Bruchkanten.
case 'core/paragraph': $blockMeta = [ 'content' => trim(strip_html_tags($block['innerHTML'])), ]; break;
'core/image'
Der Block hat entweder eine ID, die auf eine hochgeladene Mediendatei verweist, oder, falls nicht, muss die Bildquelle unter <img src="...">
extrahiert werden. Einige Attribute (Beschriftung, linkDestination, Link, Ausrichtung) sind optional.
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;
Es ist sinnvoll, die Funktionen extract_image_src
, extract_caption
und extract_link
zu erstellen, da deren reguläre Ausdrücke immer wieder für mehrere Blöcke verwendet werden. Bitte beachten Sie, dass eine Bildunterschrift in Gutenberg Links enthalten kann ( <a href="...">
), beim Aufruf von strip_html_tags
werden diese jedoch aus der Bildunterschrift entfernt.
Auch wenn es bedauerlich ist, finde ich diese Praxis unvermeidlich, da wir nicht garantieren können, dass ein Link auf Nicht-Web-Plattformen funktioniert. Dadurch gewinnt der Content zwar an Universalität, da er für verschiedene Medien verwendet werden kann, verliert aber auch an Spezifität, sodass seine Qualität schlechter ist als bei Inhalten, die für die jeweilige Plattform erstellt und angepasst wurden.
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'
Rufen Sie einfach die Video-URL aus den Blockattributen ab und extrahieren Sie ihre Beschriftung aus dem HTML-Inhalt, falls vorhanden.
case 'core-embed/youtube': $blockMeta = [ 'url' => $block['attrs']['url'], ]; if ($caption = extract_caption($block['innerHTML'])) { $blockMeta['caption'] = $caption; } break;
'core/heading'
Sowohl die Kopfzeilengröße (h1, h2, …, h6) als auch der Überschriftentext sind keine Attribute, diese müssen also dem HTML-Inhalt entnommen werden. Bitte beachten Sie, dass anstelle der Rückgabe des HTML-Tags für den Header das size
einfach eine äquivalente Darstellung ist, die agnostischer ist und für Nicht-Web-Plattformen sinnvoller ist.
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'
Leider konnte ich für die Bildergalerie die Bildunterschriften nicht aus jedem Bild extrahieren, da dies keine Attribute sind und das Extrahieren durch einen einfachen regulären Ausdruck fehlschlagen kann: Wenn es eine Bildunterschrift für das erste und dritte Element gibt, aber keine für die zweite, dann wüsste ich nicht, welche Bildunterschrift zu welchem Bild gehört (und ich habe mir nicht die Zeit genommen, eine komplexe Regex zu erstellen). Ebenso rufe ich in der folgenden Logik immer die "full"
Bildgröße ab, dies muss jedoch nicht der Fall sein, und ich weiß nicht, wie die geeignetere Größe abgeleitet werden kann.
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'
Wandeln Sie einfach die <li>
-Elemente in ein Array von Elementen um.
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'
Rufen Sie die URL der entsprechenden hochgeladenen Mediendatei ab.
case 'core/audio': $blockMeta = [ 'src' => wp_get_attachment_url($block['attrs']['id']), ]; break;
'core/file'
Während die URL der Datei ein Attribut ist, muss ihr Text aus dem inneren Inhalt extrahiert werden.
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'
Rufen Sie die Video-URL und alle Eigenschaften ab, um zu konfigurieren, wie das Video über einen regulären Ausdruck abgespielt wird. Wenn Gutenberg jemals die Reihenfolge ändert, in der diese Eigenschaften im Code gedruckt werden, wird diese Regex nicht mehr funktionieren, was eines der Probleme zeigt, wenn Metadaten nicht direkt über die Blockattribute hinzugefügt werden.
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.
SICH AUSRUHEN
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.
Darüber hinaus kann unsere Abfrage, ähnlich wie GraphQL-Argumente, durch Feldargumente angepasst werden, sodass nur die Daten abgerufen werden können, die für eine bestimmte Plattform sinnvoll sind. Wenn wir beispielsweise alle Youtube-Videos extrahieren möchten, die zu allen Beiträgen hinzugefügt wurden, können wir den Modifikator (blockname:core-embed/youtube)
zu block-metadata
in der Endpunkt-URL hinzufügen, wie in diesem Link. Oder wenn wir alle Bilder aus einem bestimmten Beitrag extrahieren möchten, können wir einen Modifikator hinzufügen (blockname:core/image)
wie in diesem anderen Link|ID|Titel).
Fazit
Die COPE-Strategie („Create Once, Publish Everywhere“) hilft uns, den Arbeitsaufwand für die Erstellung mehrerer Anwendungen zu verringern, die auf verschiedenen Medien (Web, E-Mail, Apps, Heimassistenten, virtuelle Realität usw.) ausgeführt werden müssen, indem wir eine einzige Quelle erstellen der Wahrheit für unseren Inhalt. In Bezug auf WordPress hat sich die Implementierung der COPE-Strategie historisch gesehen als Herausforderung erwiesen, obwohl es immer als Content-Management-System geglänzt hat.
Einige neuere Entwicklungen haben es jedoch zunehmend möglich gemacht, diese Strategie für WordPress zu implementieren. Auf der einen Seite sind die meisten WordPress-Inhalte seit der Integration in den Kern der WP-REST-API und insbesondere seit dem Start von Gutenberg über APIs zugänglich, was es zu einem echten Headless-System macht. Auf der anderen Seite ist Gutenberg (das ist der neue Standard-Inhaltseditor) blockbasiert, sodass alle Metadaten in einem Blogbeitrag für die APIs leicht zugänglich sind.
Daher ist die Implementierung von COPE für WordPress unkompliziert. In diesem Artikel haben wir gesehen, wie es geht, und der gesamte relevante Code wurde über mehrere Repositories verfügbar gemacht. Auch wenn die Lösung nicht optimal ist (da viel HTML-Code geparst wird), funktioniert sie dennoch ziemlich gut, mit der Folge, dass der Aufwand für die Freigabe unserer Anwendungen auf mehreren Plattformen erheblich reduziert werden kann. Kudos dafür!