Редактор Gutenberg блочный редактор wordpress для создания контентной части страницы. Он пример того, как плагин стал частью движка. В этой статье я буду не обозревать редактор, а выполнять разработку блока для расширения возможностей редактора.
Содержание
Постановка задачи
- Задача: разработать плагин, добавляющий блок последних записей в редактор.
- В блоке можно редактировать:
- Количество выводимых постов;
- Категорию постов(из существующих, не пустых)
- Статус отображения изображения записи
Я уверен, что подобные плагины существуют и, что подобный блок можно собрать используя другие плагины, на подобии ACF. Но данная задача – это реальное тестовое задание, выполнением которого я хотел бы поделится.
Теоретические выкладки
Начнем из далека – нужно же статье придать объёма!
Редактор Gutenberg – это новый(относительно, частью wp он стал в версии движка 5, до этого был плагином) визуальный редактор WordPress для записей и страниц. Проект назван именем Иогана Гутерберга, презентовавшего Европе печатный станок и начавшего печатную революцию. Его работа сделала знание и информацию доступнее и запустила социальную революцию. Аналогично этому, разработчики хотят сделать доступным создание продвинутый макетов страниц для всех пользователей WordPress(источник).
Основная особенность редактора Gutenberg – это преставление всего контента в виде блоков и определение макета записи прямо в редакторе.
Ну все, хватит копи-паста, мой внутренний борец с плагиатом не позволит более. Да, я знаю, что все мои статейки – это, всего лишь, пере озвучка документаций и других источников… но, иногда, пишу и свои наблюдения.
В общем, каждый блок редактора – скрипт написанный на javascript с использованием библиотеки react. Все, что нужно для создания блока – это зарегистрировать скрипт и стили(коль нужны); в скрипте определить зависимости и функцию(с методами и свойствами).
Опишу свои мысли о том, что я буду делать – дабы выполнить поставленную задачу. Первым делом, учитывая, что по правилам хорошего тона react общается с приложениями по средствам REST API, и нужного пути вордпресс по умолчанию не имеет – создам этот путь в WP REST API. Второе – я знаю, что в таблицу БД движок сохраняет запись о блоке; поскольку, наш блок во фронте должен быть динамичен – то запись должна хранить элемент, который вордпресс ассоциирует с функцией. Кто ранее знакомился с этим движком, понял о чем я – о шоркоде, который мне также предстоит создать. И последнее, несколько слов о javascript – внутри функции регистрации блока нужно будет определить 4 вещи: мета инфу о блоке, его атрибуты, сохраняемую строку и вид в редакторе.
Установка рабочей среды
Это действие нужно чисто для удобства и скорости разработки. Но, в принципе, код можно писать и в блокноте – но все используют специальные редакторы. Так-что, советую эти операции проделать тоже…
Первое, при любой разработке на PHP(а язык бэка wordpress именно php) понадобится локальный сервер. Я использую OpenServer: просто скачай с официального сайта и установи его.
Второе – это компилятор JSX в JS, SCSS в CSS. В качестве такого компилятора, я использую NodeJS с пакетами; далее, подробно об установке.
С официального сайта грузим и устанавливаем NodeJS. После успешной установки, зашел в командную строку, у нас появиться менеджер пакетов npm. Первое, что нужно сделать – это сформировать package.json запуском команды
npm init
и дальнейшими ответами на поставленные вопросы. После чего, в командной строке пишем
npm install --save-dev @babel/core @babel/cli
это установи транскриптор babel в зависимости для ноды. И, для полного понимания при компиляции, нужен пресет @wordpress/babel-preset-default. Запустим
npm install @wordpress/babel-preset-default --save-dev
Среда для компиляции JSX готова. Немного о нахождении компилируемого файла и самого запуска процесса компиляции. Пусть компилируемый файл называется script.js и скомпилированный script-compiled.js; как я понял наблюдением, файл script.js должен лежать на одном уровне с package.json и папкой node_modules, так как ругается на указанные пути(по крайней мере, так было у меня). Сам процесс компиляции запускается так
npx babel --presets @wordpress/default script.js --watch --out-file script-compiled.js
После окончания компиляции, будет создан script-compiled.js
Backend блока
Бэк блока, как было ранее сказано, заключается в подключении скрипта и стилей, а также, нового rest-пути и шорткода. Делается это следующим кодом
<?php function blocks_scripts() { wp_enqueue_script( 'gutenberg-latest-posts', plugins_url('/block/js/gutenberg-latest-posts-block.js'), ['wp-blocks', 'wp-element', 'wp-editor', 'wp-i18n'], true ); } add_action('enqueue_block_editor_assets', 'blocks_scripts');
но, по условию, мне нужно выбрать категорию постов. Для этого передадим список категорий и путь, по которому блок будет брать информацию о постах – конечный PHP скрипт в этой части
<?php function blocks_scripts() { $categories = get_categories(); if (!empty($categories)) { foreach ($categories as $key => $value) { $cats[$value->term_id] = $value->name; } } // скрываем блок если нет категорий if (!empty($cats)) { // подключаем блок wp_enqueue_script( 'gutenberg-latest-posts', plugins_url('/block/js/gutenberg-latest-posts-block.js'), ['wp-blocks', 'wp-element', 'wp-editor', 'wp-i18n'], true ); // создаем обьект glp, содержащий // список категорий и ссылку на WP REST API постов wp_localize_script( 'gutenberg-latest-posts', 'glp', [ 'categories' => $cats, 'restURL' => esc_url(get_rest_url(null, 'block/v1/latest-posts')) ] ); } } add_action('enqueue_block_editor_assets', 'blocks_scripts');
немного о зависимостях – скрипт wp-blocks содержит функции, необходимые для регистрации блока и образует js объект wp.blocks; wp-element содержит функции react с ворпрессовской “прокладкой”, объект wp.element; wp-i18n функции для создания translate ready, wp.18n. Зависимость wp-editor можно пропустить, но она содержит готовые функции для размещения элементов управления атрибутами блока.
Следующий шаг – создание пути ‘wp/v2/latest-posts
‘ – ведь его еще не существует. Этот код его зарегистрирует в системе
<?php add_action('rest_api_init', 'blocks_rest_endpoint'); function blocks_rest_endpoint() { register_rest_route('block/v1', '/latest-posts', [ 'method' => 'GET', 'callback' => 'blocks_return_data' ]); }
Теперь то, что он будет выводить(имеется в виду, что получит перешедший по этому пути)
<?php function blocks_return_data() { $category = isset($_GET['cat']) ? absint($_GET['cat']) : 0; $count = isset($_GET['count']) ? absint($_GET['count']) : 5; return json_encode(blocks_get_latest_posts($category, $count)); } function blocks_get_latest_posts($category, $count) { $result = []; $posts = get_posts([ 'numberposts' => $count, 'category' => $category ]); foreach ($posts as $key => $value) { $result[] = [ 'title' => get_the_title($value->ID), 'excerpt' => get_the_excerpt($value->ID), 'link' => get_permalink($value->ID), 'src' => get_the_post_thumbnail_url($value->ID, 'full'), 'id' => $value->ID ]; } return $result; }
и в конце, зарегистрируем шорткод
<?php add_shortcode('gutenberg-block-latest-posts', 'blocks_shortcode'); function blocks_shortcode($atts) { $atts = shortcode_atts([ 'count' => 5, 'cat' => array_shift(array_keys(blocks_get_categories())), 'thumb' => 1 ], $atts); $posts = blocks_get_latest_posts(absint($atts['cat']), absint($atts['count'])); ob_start(); ?> <div class="latest-posts"> <?php if (!empty($posts)): ?> <?php foreach ($posts as $key => $value): ?> <div class="post-<?php echo $value['id']; ?> row"> <?php if ($atts['thumb'] && !empty($value['src'])): ?> <div class="thumbnail"> <img src="<?php echo $value['src'] ?>" alt="<?php echo $value['title'] ?>"> </div> <?php endif; ?> <div class="post-content"> <a href="<?php echo $value['link'] ?>"> <h2><?php echo $value['title'] ?></h2> </a> <p><?php echo $value['excerpt'] ?></p> </div> </div> <?php endforeach; ?> <?php endif; ?> </div> <?php wp_reset_postdata(); return ob_get_clean(); }
Вот и весь бэк-энд блока
Frontend блока
Создадим js-файл по ранее указанному пути и, во избежание конфликтов с другими, сделаем замыкание. Замыкание выглядит как анонимная само исполняющаяся функция
(function(blocks, element, i18n, editor, data) { // code })( window.wp.blocks, window.wp.element, window.wp.i18n, window.wp.editor, window.glp );
В нижних скобках указаны те объекты, которые нужно “впустить”, сверху – под какими именами их использовать в порядке соответствия. Далее, для удобства(поймете после компиляции файла), нужно “вытянуть” некоторые методы с объектов
const { createElement } = element; const { __ } = i18n; const { registerBlockType } = blocks; const { InspectorControls } = editor; const { BlockControls } = editor;
Далее, зарегистрируем блок редактора, его описания и атрибуты, коими будем манипулировать
registerBlockType('block/gutenberg-latest-posts', { title: __('Latest Posts'), icon: 'dashicons-excerpt-view', category: 'common', attributes: { thumb: { type: 'integer', default: 1 }, count: { type: 'integer', default: 5 }, category: { type: 'integer', default: Object.keys(data.categories)[0] }, checked: { type: 'bool', default: true }, posts: { type: 'array', default: [] } } } });
поля title и icon интуитивно понятные(есть еще и другие, необязательные), скажу только, что category это блок, где будет отображен блок(блоки по умолчанию ). Чтобы создать свою категорию добавь следующий PHP-код
<?php function my_plugin_block_categories( $categories, $post ) { if ( $post->post_type !== 'post' ) { return $categories; } return array_merge( $categories, array( array( 'slug' => 'my-category', 'title' => __( 'My category', 'my-plugin' ), 'icon' => 'wordpress', ) ); } add_filter( 'block_categories', 'my_plugin_block_categories', 10, 2 );
Отдельно упомяну об атрибутах – из-за строгого отношения к типам данных, чтобы булевое значение не сохранить в БД; поэтому, приходится булевое ставить в зависимость от других типов, значение которых можно сохранить. Так у меня, выбор отображения изображения поста происходит чекбоксом(атрибут checked), состояние которого возвращается в типе bool. Чтобы сохранять это состояние, я ввел доп.атрибут в типе integer(thumb), значение которого в полной зависимостти от checked.
Осталось определить 2 метода, которые должны возвращать HTML-разметку имеющую по контейнеру. Первый(edit) отвечает за разметку в редакторе блоков, второй(save) – за то, что будет сохранено в БД. Начнем с первого(полный код, написанный мною)
edit: ({attributes, setAttributes}) => { function Item(props) { return ( <div class={ 'row post-' + props.props.id }> { (() => { if (attributes.thumb && props.props.src.length) { return ( <div class="thumbnail"> <img src={ props.props.src } alt={ props.props.title }/> </div> ); } })() } <div class="post-excerpt"> <a href={ props.props.link } onClick={ abort }><h3>{ props.props.title }</h3></a> <p>{ props.props.excerpt }</p> </div> </div> ); } function setCount(event) { setAttributes({count:parseInt(event.target.value)}); setPosts(attributes.category, event.target.value); } function setCategory(event) { setAttributes({category:parseInt(event.target.value)}); setPosts(event.target.value, attributes.count); } function setThumb(event) { if (event.target.checked) { setAttributes({ thumb: 1, checked: true }); } else { setAttributes({ thumb: 0, checked: false }); } } function abort(event) { event.preventDefault(); } function setPosts(category, count) { var xhr = new XMLHttpRequest(); xhr.open('GET', data.restURL + '?cat=' + category + '&count=' + count); xhr.onload = function () { setAttributes({posts:JSON.parse(xhr.responseText)}); }; xhr.send(); } return ( <div className={ attributes.className }> <BlockControls> <h3>{ __('Latest Posts') }</h3> </BlockControls> <InspectorControls> <p> <label>{ __('Posts Count') }</label><br/> <input type="number" value={ attributes.count } onChange={ setCount } min="1"/> </p> <p> <label>{ __('Posts Category') }</label><br/> { (() => { let option = Object.keys(data.categories).map((i) => { return ( <option value={ i }>{ data.categories[i] }</option> ); }); return ( <select value={ attributes.category } onChange={ setCategory }> { option } </select> ) })() } </p> <p> <label> <input type="checkbox" checked={ attributes.checked } onChange={ setThumb }/> { __('Show Thumbnail') } </label> </p> </InspectorControls> { (() => { if (!attributes.posts.length) { setPosts(attributes.category, attributes.count); } let items = attributes.posts.map((post) => { return ( <Item props={ post }/> ); }); return ( <div class="preview"> { items } </div> ); })() } </div> ); }
немного о написанном: функции setCount, setCategory и setThumb – для изменения состояния атрибутов; Item и ее вызов кодом <Item /> – чисто реактовский прикол; setPosts получает список постов по нашему маршруту, и устанавливает его в атрибут posts. BlockControls и InspectorControls – методы объекта wp.editor: первый размещает внутреннюю разметку в верхней панели(toolbar) блока, второй – в боковой панели.
И, наконец-то, edit. Он очень прост, если бы разрабатываемый блок был стационарен – он возвращал бы такую же разметку, как и основное поле блока
save: ({attributes}) => { return ( <div class="latest-posts-block"> [gutenberg-block-latest-posts cat="{ attributes.category }" count="{ attributes.count }" thumb="{ attributes.thumb }"] </div> ); }
но поскольку он динамичен – содержит мой шорткод. Обратите внимание, поскольку результатом должна быть HTML-разметка – я обернул шорткод в тег.
Заключение
После компиляции js был получен код регистрации нового блока. Забыл сказать в основном теле статьи – особенностью react есть то, что он рендерит по фреймам, а не по клику. Из этого следует, что любое действие следует “запирать” в функцию. Весь код выполненного тестового лежит здесь.