Редактор 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 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 есть то, что он рендерит по фреймам, а не по клику. Из этого следует, что любое действие следует “запирать” в функцию. Весь код выполненного тестового лежит здесь.