WordPress로 "한 번 만들고 모든 곳에 게시"

게시 됨: 2022-03-10
빠른 요약 ↬ COPE("한 번 만들고 모든 곳에 게시")라는 용어는 모든 콘텐츠에 대한 단일 정보 소스를 사용하여 다양한 출력(웹사이트, AMP 사이트, 이메일, 앱 등)에 콘텐츠를 게시하는 방법론입니다. . WordPress를 사용하여 COPE를 구현하는 방법을 살펴보겠습니다.

COPE는 웹사이트, 이메일, 앱 등과 같은 다양한 매체에 콘텐츠를 게시하는 데 필요한 작업량을 줄이기 위한 전략입니다. NPR이 처음 개척한 이 플랫폼은 모든 다양한 매체에 사용할 수 있는 콘텐츠에 대한 단일 정보 소스를 구축함으로써 목표를 달성합니다.

각 매체마다 고유한 요구 사항이 있기 때문에 어디에서나 작동하는 콘텐츠를 갖는 것은 쉬운 일이 아닙니다. 예를 들어 HTML은 웹용 콘텐츠 인쇄에 유효하지만 이 언어는 iOS/Android 앱에는 유효하지 않습니다. 마찬가지로 웹용 HTML에 클래스를 추가할 수 있지만 이메일용 스타일로 변환해야 합니다.

이 수수께끼에 대한 해결책은 형식과 내용을 분리하는 것입니다. 내용의 표현과 의미는 분리되어야 하며, 의미만 진실의 단일 소스로 사용됩니다. 그런 다음 프레젠테이션을 다른 레이어에 추가할 수 있습니다(선택한 매체에만 해당).

예를 들어 다음 HTML 코드에서 <p> 는 주로 웹에 적용되는 HTML 태그이고 class="align-center" 속성은 프레젠테이션입니다(요소를 "중앙에" 배치하는 것은 화면 기반 매체(Amazon Alexa와 같은 오디오 기반 매체 제외):

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

따라서 이 콘텐츠는 단일 소스로 사용할 수 없으며 다음 JSON 코드와 같이 프레젠테이션과 의미를 구분하는 형식으로 변환해야 합니다.

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

이 코드 조각은 웹에 사용할 HTML 코드를 다시 한 번 재생성하고 다른 매체에 적합한 형식을 확보할 수 있기 때문에 콘텐츠에 대한 단일 정보 소스로 사용할 수 있습니다.

점프 후 더! 아래에서 계속 읽기 ↓

왜 워드프레스

WordPress는 다음과 같은 몇 가지 이유로 COPE 전략을 구현하는 데 이상적입니다.

  • 다재다능합니다.
    WordPress 데이터베이스 모델은 고정된 경직된 콘텐츠 모델을 정의하지 않습니다. 반대로 게시물 및 사용자 지정 게시물 유형, 사용자, 댓글 및 분류( 태그 및 카테고리).
  • 강력하다.
    워드프레스는 CMS(콘텐츠 관리 시스템)로 빛을 발하며 플러그인 생태계를 통해 새로운 기능을 쉽게 추가할 수 있습니다.
  • 널리 퍼져 있습니다.
    웹 사이트의 1/3이 WordPress에서 실행되는 것으로 추정됩니다. 그러면 웹에서 작업하는 상당한 수의 사람들이 WordPress에 대해 알고 사용할 수 있습니다. 개발자뿐만 아니라 블로거, 영업 사원, 마케팅 직원 등이 있습니다. 그러면 기술적 배경에 상관없이 다양한 이해 관계자가 단일 정보 소스 역할을 하는 콘텐츠를 생성할 수 있습니다.
  • 머리가 없다.
    헤드리스는 프레젠테이션 계층에서 콘텐츠를 분리하는 기능이며 COPE를 구현하기 위한 기본 기능입니다(이종 매체에 데이터를 공급할 수 있음).

    버전 4.7부터 코어에 WP REST API를 통합하고 버전 5.0에서 Gutenberg가 출시된 이후(많은 REST API 엔드포인트를 구현해야 함), WordPress는 대부분의 WordPress 콘텐츠 때문에 헤드리스 CMS로 간주될 수 있습니다. 모든 스택에 구축된 모든 애플리케이션에서 REST API를 통해 액세스할 수 있습니다.

    또한 최근에 생성된 WPGraphQL은 WordPress와 GraphQL을 통합하여 점점 더 많이 사용되는 이 API를 사용하여 WordPress의 콘텐츠를 모든 애플리케이션에 제공할 수 있습니다. 마지막으로 내 프로젝트 PoP는 최근 WordPress 데이터를 REST, GraphQL 또는 PoP 기본 형식으로 내보낼 수 있는 API 구현을 추가했습니다.
  • 여기에는 블록 개념에 기반을 두고 있기 때문에 COPE 구현을 크게 지원하는 블록 기반 편집기인 Gutenberg 가 있습니다(아래 섹션에서 설명됨).

정보를 나타내는 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>

알 수 있듯이 이 블로그 게시물의 중요한 정보(예: 단락의 내용, URL, Youtube 비디오의 크기 및 속성)는 쉽게 액세스할 수 없습니다. 자체적으로 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" 인 항목을 선택하고 추출하면 됩니다. 그들로부터의 URL. 마찬가지로 Apple Watch에 비디오를 표시하려는 경우 비디오의 크기에 신경 쓸 필요가 없으므로 widthheight 속성을 간단하게 무시할 수 있습니다.

구텐베르크가 블록을 구현하는 방법

WordPress 버전 5.0 이전에는 WordPress에서 Blob을 사용하여 데이터베이스에 게시물 콘텐츠를 저장했습니다. 버전 5.0부터 WordPress는 블록 기반 편집기인 Gutenberg와 함께 제공되어 위에서 언급한 콘텐츠를 처리하는 향상된 방법을 가능하게 하며, 이는 COPE 구현을 향한 돌파구를 나타냅니다. 불행히도 Gutenberg는 이 특정 사용 사례를 위해 설계되지 않았으며 정보의 표현이 블록에 대해 설명된 것과 다르기 때문에 처리해야 하는 몇 가지 불편을 초래합니다.

먼저 위에서 설명한 블로그 게시물이 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} --> 이 정보를 웹사이트에 표시할 때 표시되지 않도록 하는 HTML 주석입니다. 그러나 HTML 주석이 깨져 콘텐츠로 나타날 수 있으므로 다른 매체에 블로그 게시물을 직접 표시할 수 없습니다. parse_blocks($content) 함수를 통해 콘텐츠를 구문 분석한 후 HTML 주석이 제거되고 블록 데이터를 JSON 개체로 직접 사용할 수 있기 때문에 이것은 큰 문제가 아닙니다.

블록에 HTML 포함

단락 블록의 내용은 "이 멋진 탱고를 "Look at this wonderful tango:" 대신 "<p>Look at this wonderful tango:</p>" 입니다. 따라서 여기에는 다른 매체에 유용하지 않은 HTML 코드(태그 <p></p> )가 포함되어 있으므로 예를 들어 PHP 함수 strip_tags($content) 를 통해 제거해야 합니다.

태그를 제거할 때 <strong><em> 태그와 같이 의미 정보를 명시적으로 전달하는 HTML 태그를 유지할 수 있습니다(화면 기반 매체에만 적용되는 <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; }

이것은 구텐베르크 블록에서 모든 메타데이터를 추출하기 위해 극복해야 하는 장애물입니다. 이것은 여러 블록에서 발생합니다. 모든 메타데이터 조각이 속성으로 저장되는 것은 아니므로 먼저 이러한 메타데이터 조각을 식별한 다음 HTML 콘텐츠를 구문 분석하여 블록 단위 및 조각 단위로 추출해야 합니다.

COPE와 관련하여 이것은 정말 최적의 솔루션을 가질 수 있는 기회를 낭비하는 것입니다. 대안 옵션도 이상적이지 않다고 주장할 수 있습니다. 정보를 복제하여 HTML 내에 저장하고 속성으로 저장하여 DRY( D on't R repeat Y ourself) 원칙을 위반하기 때문입니다. 그러나 이 위반은 이미 발생했습니다. 예를 들어, className 속성에는 "wp-embed-aspect-16-9 wp-has-aspect-ratio" 값이 포함되어 있으며, 이는 HTML 속성 class 아래의 콘텐츠 내부에도 인쇄됩니다.

Gutenberg를 통해 콘텐츠 추가
Gutenberg를 통해 콘텐츠 추가(큰 미리보기)

COPE 구현

참고: 아래에 설명된 모든 코드를 포함하여 이 기능을 WordPress 플러그인 블록 메타데이터로 출시했습니다. COPE의 힘을 맛볼 수 있도록 설치하고 가지고 놀 수 있습니다. 소스 코드는 이 GitHub 리포지토리에서 사용할 수 있습니다.

이제 블록의 내부 표현이 어떻게 생겼는지 알았으므로 구텐베르크를 통해 COPE를 구현해 보겠습니다. 절차에는 다음 단계가 포함됩니다.

  1. parse_blocks($content) 함수는 중첩된 수준의 JSON 객체를 반환하기 때문에 먼저 이 구조를 단순화해야 합니다.
  2. 우리는 모든 블록을 반복하고 각각에 대해 메타데이터 조각을 식별하고 추출하여 프로세스에서 매체에 구애받지 않는 형식으로 변환합니다. 응답에 추가되는 속성은 블록 유형에 따라 다를 수 있습니다.
  3. 마지막으로 API(REST/GraphQL/PoP)를 통해 데이터를 사용할 수 있도록 합니다.

이 단계를 하나씩 구현해 보겠습니다.

1. JSON 객체의 구조 단순화

parse_blocks($content) 함수에서 반환된 JSON 객체는 중첩 아키텍처를 가지며, 일반 블록에 대한 데이터는 첫 번째 수준에 나타나지만 참조된 재사용 가능한 블록에 대한 데이터는 누락됩니다(참조하는 블록에 대한 데이터만 추가됨). 중첩 블록(다른 블록 내에 추가됨) 및 그룹화된 블록(여러 블록을 함께 그룹화할 수 있음)에 대한 데이터는 하나 이상의 하위 수준 아래에 나타납니다. 이 아키텍처는 포스트 콘텐츠의 모든 블록에서 블록 데이터를 처리하기 어렵게 만듭니다. 한 쪽에서는 일부 데이터가 누락되고 다른 쪽에서는 데이터가 얼마나 많은 수준에 있는지 사전에 알지 못하기 때문입니다. 또한, 안전하게 무시할 수 있는 내용이 포함되지 않은 모든 블록 쌍에 블록 구분선이 있습니다.

예를 들어, 단순 블록, 전역 블록, 단순 블록이 포함된 중첩 블록, 단순 블록 그룹이 포함된 게시물에서 얻은 응답은 다음과 같습니다.

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

게시물 내용( $post->post_content )을 매개변수로 전달하는 get_block_data($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_captionextract_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 콘텐츠에서 가져와야 합니다. 헤더에 대한 HTML 태그를 반환하는 대신 size 속성은 단순히 동등한 표현이며, 이는 더 불가지론적이며 웹이 아닌 플랫폼에 더 적합합니다.

 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과 모든 속성을 가져와 정규식을 통해 비디오가 재생되는 방식을 구성합니다. 구텐베르크가 코드에서 이러한 속성이 인쇄되는 순서를 변경하면 이 정규식이 작동을 멈추고 블록 속성을 통해 직접 메타데이터를 추가하지 않는 문제 중 하나를 나타냅니다.

 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 비디오를 추출하려는 경우 이 링크와 같이 엔드포인트 URL의 필드 block-metadata 에 수정자 (blockname:core-embed/youtube) 를 추가할 수 있습니다. 또는 특정 게시물에서 모든 이미지를 추출하려면 이 다른 link|id|title에서와 같이 modifier (blockname:core/image) 를 추가할 수 있습니다.

결론

COPE("한 번 생성, 어디에나 게시") 전략은 단일 소스를 생성하여 다양한 매체(웹, 이메일, 앱, 홈 어시스턴트, 가상 현실 등)에서 실행되어야 하는 여러 애플리케이션을 생성하는 데 필요한 작업량을 줄이는 데 도움이 됩니다. 우리 콘텐츠에 대한 진실. WordPress와 관련하여 항상 콘텐츠 관리 시스템으로 빛을 발했지만 COPE 전략을 구현하는 것은 역사적으로 어려운 일이었습니다.

그러나 최근 몇 가지 개발로 인해 WordPress에 이 전략을 구현하는 것이 점점 더 가능해졌습니다. 한편으로는 WP REST API의 핵심으로 통합된 이후, 그리고 Gutenberg의 출시 이후 더욱 두드러지게 나타났으며, 대부분의 WordPress 콘텐츠는 API를 통해 액세스할 수 있어 진정한 헤드리스 시스템이 되었습니다. 반면 Gutenberg(새로운 기본 콘텐츠 편집기)는 블록 기반이므로 블로그 게시물 내의 모든 메타데이터에 API에 쉽게 액세스할 수 있습니다.

결과적으로 WordPress용 COPE를 구현하는 것은 간단합니다. 이 기사에서 우리는 그것을 하는 방법을 보았고 모든 관련 코드는 여러 리포지토리를 통해 사용할 수 있게 되었습니다. 솔루션이 최적이 아닐지라도(많은 파싱 HTML 코드가 포함되기 때문에) 여전히 상당히 잘 작동하므로 여러 플랫폼에 애플리케이션을 릴리스하는 데 필요한 노력을 크게 줄일 수 있습니다. 그 점에 경의를 표합니다!