使用 WordPress “創建一次,隨處發布”
已發表: 2022-03-10COPE 是一種減少將我們的內容髮佈到不同媒體(如網站、電子郵件、應用程序等)所需的工作量的策略。 它首先由 NPR 首創,它通過為可用於所有不同媒體的內容建立單一事實來源來實現其目標。
擁有適用於任何地方的內容並非易事,因為每種媒體都有自己的要求。 例如,雖然 HTML 對打印 Web 內容有效,但這種語言對 iOS/Android 應用程序無效。 類似地,我們可以為 web 的 HTML 添加類,但這些必須轉換為電子郵件的樣式。
解決這個難題的方法是形式與內容分離:內容的呈現和意義必須解耦,只有意義作為唯一的真相來源。 然後可以將演示文稿添加到另一層(特定於所選媒體)。
例如,給定以下 HTML 代碼, <p>
是一個 HTML 標記,主要適用於 Web,屬性class="align-center"
是表示(將元素放在“中心”對於基於屏幕的媒體,但不適用於基於音頻的媒體,例如 Amazon Alexa):
<p class="align-center">Hello world!</p>
因此,這段內容不能作為單一的事實來源,必須將其轉換為一種將意思與表示分開的格式,例如以下一段 JSON 代碼:
{ content: "Hello world!", placement: "center", type: "paragraph" }
這段代碼可以用作內容的單一真實來源,因為我們可以從中重新創建用於網絡的 HTML 代碼,並為其他媒體獲取適當的格式。
為什麼選擇 WordPress
由於以下幾個原因,WordPress 是實施 COPE 策略的理想選擇:
- 它用途廣泛。
WordPress 數據庫模型沒有定義一個固定的、死板的內容模型; 相反,它是為多功能性而創建的,可以通過使用元字段來創建各種內容模型,這允許為四種不同的實體存儲額外的數據:帖子和自定義帖子類型、用戶、評論和分類(標籤和類別)。 - 它很強大。
WordPress 作為 CMS(內容管理系統)大放異彩,其插件生態系統可以輕鬆添加新功能。 - 它很普遍。
據估計,有 1/3 的網站在 WordPress 上運行。 然後,在網絡上工作的相當數量的人知道並且能夠使用,即 WordPress。 不僅是開發人員,還有博主、銷售人員、營銷人員等等。 然後,許多不同的利益相關者,無論他們的技術背景如何,都將能夠產生作為單一事實來源的內容。 - 它是無頭的。
無頭是一種將內容與表示層分離的能力,它是實現 COPE 的基本特徵(以便能夠將數據饋送到不同的媒體)。
自從從 4.7 版開始將 WP REST API 合併到核心中,並且自 5.0 版中推出 Gutenberg(為此必須實現大量 REST API 端點)以來更為顯著,WordPress 可以被視為無頭 CMS,因為大多數 WordPress 內容構建在任何堆棧上的任何應用程序都可以通過 REST API 訪問。
此外,最近創建的 WPGraphQL 集成了 WordPress 和 GraphQL,可以使用這個日益流行的 API 將 WordPress 中的內容饋送到任何應用程序中。 最後,我自己的項目 PoP 最近添加了一個用於 WordPress 的 API 實現,它允許將 WordPress 數據導出為 REST、GraphQL 或 PoP 原生格式。 - 它有Gutenberg ,這是一個基於塊的編輯器,它極大地幫助了 COPE 的實現,因為它基於塊的概念(如下面的部分所述)。
表示信息的 Blob 與塊
Blob 是一個單一的信息單元,它們一起存儲在數據庫中。 例如,在依賴 blob 存儲信息的 CMS 上編寫下面的博客文章會將博客文章內容存儲在單個數據庫條目中 - 包含相同的內容:
<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>
可以理解,這篇博文中的重要信息(例如段落中的內容,以及 Youtube 視頻的 URL、尺寸和屬性)並不容易獲得:如果我們想要檢索其中的任何一個就其本身而言,我們需要解析 HTML 代碼以提取它們——這遠非理想的解決方案。
塊的作用不同。 通過將信息表示為塊列表,我們可以以更具語義和可訪問性的方式存儲內容。 每個塊傳達它自己的內容和它自己的屬性,這些屬性可以取決於它的類型(例如,它可能是一個段落還是一個視頻?)。
例如,上面的 HTML 代碼可以表示為這樣的塊列表:
{ [ 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" ] }
通過這種表示信息的方式,我們可以輕鬆地單獨使用任何一條數據,並使其適應必須顯示的特定介質。 例如,如果我們想從博客文章中提取所有視頻以顯示在汽車娛樂系統上,我們可以簡單地迭代所有信息塊,選擇type="embed"
和provider="Youtube"
的那些,然後提取來自他們的網址。 同樣,如果我們想在 Apple Watch 上顯示視頻,我們不需要關心視頻的尺寸,因此我們可以直接忽略屬性width
和height
。
古騰堡如何實現塊
在 WordPress 5.0 版之前,WordPress 使用 blob 將帖子內容存儲在數據庫中。 從 5.0 版本開始,WordPress 附帶 Gutenberg,一個基於塊的編輯器,啟用了處理上述內容的增強方式,這代表了 COPE 實施的突破。 不幸的是,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 -->
從這段代碼中,我們可以做出以下觀察:
塊一起保存在同一個數據庫條目中
上面的代碼中有兩個塊:
<!-- 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 -->
除了全局(也稱為“可重用”)塊,它們在數據庫中有自己的條目並且可以通過它們的 ID 直接引用,所有塊都一起保存在表wp_posts
中的博客文章條目中。
因此,要檢索特定塊的信息,我們首先需要解析內容並將所有塊相互隔離。 方便的是,WordPress 提供了函數parse_blocks($content)
來做到這一點。 此函數接收包含博客文章內容(HTML 格式)的字符串,並返回一個 JSON 對象,其中包含所有包含塊的數據。
塊類型和屬性通過 HTML 註釋傳達
每個塊由一個起始標籤<!-- wp:{block-type} {block-attributes-encoded-as-JSON} -->
和一個結束標籤<!-- /wp:{block-type} -->
分隔<!-- /wp:{block-type} -->
which(作為 HTML 註釋)確保該信息在網站上顯示時不可見。 但是,我們不能直接在其他媒體上顯示博客文章,因為 HTML 評論可能是可見的,顯示為亂碼內容。 不過這沒什麼大不了的,因為通過函數parse_blocks($content)
解析內容後,HTML 註釋被刪除,我們可以直接將塊數據作為 JSON 對象進行操作。
塊包含 HTML
段落塊的內容是"<p>Look at this wonderful tango:</p>"
,而不是"Look at this wonderful tango:"
。 因此,它包含對其他媒體無用的 HTML 代碼(標籤<p>
和</p>
),因此必須刪除,例如通過 PHP 函數strip_tags($content)
。
在剝離標籤時,我們可以保留那些明確傳達語義信息的 HTML 標籤,例如標籤<strong>
和<em>
(而不是它們的對應物<b>
和<i>
,它們僅適用於基於屏幕的媒體),並且刪除所有其他標籤。 這是因為語義標籤也很有可能被其他媒體正確解釋(例如,Amazon Alexa 可以識別標籤<strong>
和<em>
,並在閱讀一段文本時相應地改變其語音和語調)。 為此,我們使用包含允許標籤的第二個參數調用strip_tags
函數,並將其放置在包裝函數中以方便起見:
function strip_html_tags($content) { return strip_tags($content, '<strong><em>'); }
視頻的標題保存在 HTML 中,而不是作為屬性
從 Youtube 視頻塊中可以看出,標題"An exquisite tango performance"
存儲在 HTML 代碼中(由標籤<figcaption />
括起來),但不在 JSON 編碼的屬性對像中。 因此,要提取標題,我們需要解析塊內容,例如通過正則表達式:
function extract_caption($content) { $matches = []; preg_match('/<figcaption>(.*?)<\/figcaption>/', $content, $matches); if ($caption = $matches[1]) { return strip_html_tags($caption); } return null; }
這是我們必須克服的障礙,以便從 Gutenberg 塊中提取所有元數據。 這發生在幾個區塊上; 由於並非所有元數據都保存為屬性,因此我們必須首先確定哪些是這些元數據,然後解析 HTML 內容以逐塊和逐塊地提取它們。
關於 COPE,這代表浪費了獲得真正最佳解決方案的機會。 可以說替代選項也不理想,因為它會復制信息,將其存儲在 HTML 中和作為屬性,這違反了 DRY( D on't R epeat Y ourself)原則。 但是,這種違規行為確實已經發生:例如,屬性className
包含值"wp-embed-aspect-16-9 wp-has-aspect-ratio"
,該值也打印在內容中,位於 HTML 屬性class
下。

實施 COPE
注意:我已經發布了這個功能,包括下面描述的所有代碼,作為 WordPress 插件塊元數據。 歡迎您安裝並使用它,這樣您就可以體驗 COPE 的強大功能。 源代碼可在此 GitHub 存儲庫中找到。
現在我們知道了塊的內部表示是什麼樣的,讓我們繼續通過 Gutenberg 實現 COPE。 該程序將涉及以下步驟:
- 因為函數
parse_blocks($content)
返回一個帶有嵌套層級的 JSON 對象,我們必須首先簡化這個結構。 - 我們迭代所有塊,並為每個塊識別它們的元數據片段並提取它們,在此過程中將它們轉換為與媒體無關的格式。 將哪些屬性添加到響應中可能因塊類型而異。
- 我們最終通過 API (REST/GraphQL/PoP) 提供數據。
讓我們一一實現這些步驟。
1. 簡化 JSON 對象的結構
從函數parse_blocks($content)
返回的 JSON 對象具有嵌套架構,其中普通塊的數據出現在第一級,但缺少引用的可重用塊的數據(僅添加引用塊的數據),嵌套塊(在其他塊中添加)和分組塊(幾個塊可以組合在一起)的數據出現在 1 個或多個子級別下。 這種架構使得處理帖子內容中所有塊的塊數據變得困難,因為一方面缺少一些數據,另一方面我們不知道先驗數據位於多少級別。 此外,每對塊都有一個塊分隔符,不包含任何內容,可以安全地忽略。
例如,從包含簡單塊、全局塊、包含簡單塊的嵌套塊和一組簡單塊的帖子中獲得的響應按順序如下:
[ // 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" ] } ]
更好的解決方案是將所有數據都放在第一層,這樣遍歷所有塊數據的邏輯就大大簡化了。 因此,我們必須為這些可重用/嵌套/分組的塊獲取數據,並將其添加到第一級。 從上面的 JSON 代碼中可以看出:
- 空分隔塊具有值為
NULL
的屬性"blockName"
- 對可重用塊的引用通過
$block["attrs"]["ref"]
定義 - 嵌套塊和組塊在
$block["innerBlocks"]
下定義它們包含的塊
因此,以下 PHP 代碼刪除了空的分隔塊,識別可重用/嵌套/分組的塊並將它們的數據添加到第一級,並從所有子級中刪除所有數據:
/** * 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); }
調用函數get_block_data($content)
傳遞帖子內容( $post->post_content
)作為參數,我們現在獲得以下響應:
[[ { "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" ] } ]
儘管不是絕對必要,但創建一個 REST API 端點來輸出我們的新函數get_block_data($content)
的結果非常有幫助,這將使我們能夠輕鬆了解特定帖子中包含哪些塊,以及它們是如何結構化的。 下面的代碼在/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; }
要查看它的實際效果,請查看導出此帖子數據的鏈接。
2. 將所有塊元數據提取為媒介不可知的格式
在這個階段,我們有包含不適合 COPE 的 HTML 代碼的塊數據。 因此,我們必須去除每個塊的非語義 HTML 標籤,以將其轉換為與媒介無關的格式。
我們可以決定哪些是必須在塊類型的基礎上提取的屬性(例如,提取"paragraph"
塊的文本對齊屬性、 "youtube embed"
塊的視頻 URL 屬性等) .
正如我們之前看到的,並非所有屬性實際上都保存為塊屬性,而是保存在塊的內部內容中,因此,對於這些情況,我們需要使用正則表達式解析 HTML 內容以提取這些元數據。
在檢查了通過 WordPress 核心發布的所有塊之後,我決定不為以下塊提取元數據:
"core/columns" "core/column" "core/cover" | 這些僅適用於基於屏幕的媒體,並且(作為嵌套塊)很難處理。 |
"core/html" | 這僅對網絡有意義。 |
"core/table" "core/button" "core/media-text" | 我不知道如何以與媒體無關的方式來表示他們的數據,或者它是否有意義。 |
這給我留下了以下塊,我將繼續提取它們的元數據:
-
'core/paragraph'
-
'core/image'
-
'core-embed/youtube'
(作為所有'core-embed'
塊的代表) -
'core/heading'
-
'core/gallery'
-
'core/list'
-
'core/audio'
-
'core/file'
-
'core/video'
-
'core/code'
-
'core/preformatted'
-
'core/quote'
和'core/pullquote'
-
'core/verse'
為了提取元數據,我們創建函數get_block_metadata($block_data)
接收一個數組,其中包含每個塊的塊數據(即我們之前實現的函數get_block_data
的輸出),並且取決於塊類型(在屬性"blockName"
下提供) ),決定需要哪些屬性以及如何提取它們:
/** * 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; }
讓我們一一提取每種塊類型的元數據:
“core/paragraph”
只需從內容中刪除 HTML 標記,然後刪除尾隨斷線。
case 'core/paragraph': $blockMeta = [ 'content' => trim(strip_html_tags($block['innerHTML'])), ]; break;
'core/image'
該塊具有引用上傳媒體文件的 ID,或者,如果沒有,則必須從<img src="...">
下提取圖像源。 幾個屬性(caption、linkDestination、link、alignment)是可選的。
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;
創建函數extract_image_src
、 extract_caption
和extract_link
是有意義的,因為它們的正則表達式將一次又一次地用於多個塊。 請注意,Gutenberg 中的標題可以包含鏈接( <a href="...">
),但是,當調用strip_html_tags
時,這些鏈接會從標題中刪除。
儘管很遺憾,但我發現這種做法是不可避免的,因為我們不能保證在非網絡平台上工作的鏈接。 因此,儘管內容由於可以用於不同的媒體而獲得普遍性,但它也失去了特異性,因此與為特定平台創建和定制的內容相比,其質量較差。
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'
只需從塊屬性中檢索視頻 URL,並從 HTML 內容中提取其標題(如果存在)。
case 'core-embed/youtube': $blockMeta = [ 'url' => $block['attrs']['url'], ]; if ($caption = extract_caption($block['innerHTML'])) { $blockMeta['caption'] = $caption; } break;
'core/heading'
標題大小(h1、h2、...、h6)和標題文本都不是屬性,因此必須從 HTML 內容中獲取。 請注意, size
屬性不是返回標頭的 HTML 標記,而是簡單的等效表示,它更不可知,並且對非 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'
不幸的是,對於圖片庫,我無法從每個圖像中提取標題,因為這些不是屬性,並且通過簡單的正則表達式提取它們可能會失敗:如果第一個和第三個元素有標題,但沒有第二個,那麼我不知道哪個標題對應於哪個圖像(而且我沒有花時間創建一個複雜的正則表達式)。 同樣,在下面的邏輯中,我總是檢索"full"
圖像大小,但是,不一定是這種情況,而且我不知道如何推斷出更合適的大小。
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'
只需將<li>
元素轉換為項目數組。
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'
獲取對應上傳的媒體文件的URL。
case 'core/audio': $blockMeta = [ 'src' => wp_get_attachment_url($block['attrs']['id']), ]; break;
'core/file'
雖然文件的 URL 是一個屬性,但它的文本必須從內部內容中提取。
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'
獲取視頻 URL 和所有屬性以通過正則表達式配置視頻的播放方式。 如果 Gutenberg 改變了這些屬性在代碼中打印的順序,那麼這個正則表達式將停止工作,這證明了不直接通過塊屬性添加元數據的問題之一。
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.
休息
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); } ] ); });
流行音樂
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.
此外,與 GraphQL 參數類似,我們的查詢可以通過字段參數進行自定義,從而僅獲取對特定平台有意義的數據。 例如,如果我們希望提取添加到所有帖子中的所有 Youtube 視頻,我們可以添加修飾符(blockname:core-embed/youtube)
到端點 URL 中的字段block-metadata
,就像在這個鏈接中一樣。 或者,如果我們想從特定帖子中提取所有圖像,我們可以添加修飾符(blockname:core/image)
,就像在這個其他鏈接|id|title 中一樣。
結論
COPE(“一次創建,隨處發布”)策略通過創建單一來源,幫助我們減少創建必須在不同媒體(網絡、電子郵件、應用程序、家庭助理、虛擬現實等)上運行的多個應用程序所需的工作量我們內容的真實性。 關於 WordPress,儘管它一直作為內容管理系統大放異彩,但實施 COPE 策略在歷史上被證明是一個挑戰。
然而,最近的一些發展使得為 WordPress 實施這種策略變得越來越可行。 一方面,自從集成到 WP REST API 的核心中,尤其是自 Gutenberg 推出以來,大多數 WordPress 內容都可以通過 API 訪問,使其成為真正的無頭系統。 另一方面,Gutenberg(它是新的默認內容編輯器)是基於塊的,使得博客文章中的所有元數據都可以很容易地被 API 訪問。
因此,為 WordPress 實施 COPE 很簡單。 在本文中,我們已經了解瞭如何做到這一點,並且所有相關代碼都已通過多個存儲庫提供。 儘管該解決方案不是最佳的(因為它涉及大量解析 HTML 代碼),但它仍然運行良好,因此可以大大減少將我們的應用程序發佈到多個平台所需的工作量。 對此表示敬意!