PageBlocks Free хранит контент не в обычном поле content, а в отдельной таблице с JSON-данными. Это удобно для верстки и повторного использования элементов, но создаёт проблему: стандартный поиск по сайту не «видит» текст внутри этих блоков.
Ниже показано, как сделать простой, но рабочий поиск, который будет находить страницы по тексту из PageBlocks, сохранять его в TV-поле и использовать в Fenom-шаблоне страницы поиска.
- Архитектура решения
- Шаг 1. Подготовка TV-поля для индекса
- Шаг 2. Плагин для индексации PageBlocks (OnDocFormSave)
- Код плагина
- Шаг 3. Массовая переиндексация существующих ресурсов (через Console)
- Код для Console
- Шаг 4. Страница поиска на Fenom с учётом PageBlocks
- Форма поиска (в шапке)
- Форма поиска (в сайдбаре блога)
- Страница «Результаты поиска»
- Пример чанка chunks/search/searchResult.tpl
- Шаг 5. Тестирование и отладка
- Бонус: подсветка найденного текста (Highlighting)
- Обновляем чанк searchResult.tpl
- Итог
Архитектура решения
Задача: научить MODX индексировать контент из таблицы modx_pb_block_values (PageBlocks) и использовать его в поиске.
Будем действовать так:
- создаём отдельное TV-поле, куда будет складываться «плоский» текст из всех блоков PageBlocks ресурса;
- пишем плагин на событие
OnDocFormSave, который:- берёт все блоки PageBlocks для ресурса;
- извлекает из JSON только текстовые поля;
- очищает их от HTML и лишних данных;
- записывает результат в TV
pb_search_index;
- один раз массово переиндексируем все существующие ресурсы через консольный код;
- на странице поиска добавим условие, чтобы поиск шёл не только по
pagetitle,introtext,content, но и поpb_search_indexчерез pdoTools/Fenom.
В итоге администраторам по-прежнему удобно работать с PageBlocks, а пользователи получат полноценный поиск по всему контенту.
Шаг 1. Подготовка TV-поля для индекса
Сначала создаётся TV, в которое будет складываться свёрнутый текст из PageBlocks.
- Зайдите: Элементы → TV-переменные → Создать TV.
- Параметры:
- Имя:
pb_search_index - Заголовок: «Индекс контента PageBlocks» (любой удобный)
- Тип ввода:
TextareaилиText(можно потом скрыть в форме ресурса).
- Имя:
- Привяжите TV к нужным шаблонам (тем, где используется PageBlocks).
- Выходной тип:
Текст/По умолчанию.
Ресурсам пока ничего руками заполнять не нужно — поле будет заполняться автоматически плагином.
Шаг 2. Плагин для индексации PageBlocks (OnDocFormSave)
Теперь создаётся плагин, который при каждом сохранении ресурса соберёт текст из PageBlocks и запишет в TV.
Событие: только OnDocFormSave — этого достаточно.
Код плагина
Создайте новый плагин, назовите, например, pbSearchIndexer и привяжите к событию OnDocFormSave.
<?php
/**
* PageBlocks Search Indexer
* События: OnDocFormSave
*/
if ($modx->event->name !== 'OnDocFormSave') {
return;
}
// Защита от повторных вызовов
static $processing = array();
$resourceId = $resource->get('id');
if (isset($processing[$resourceId])) {
return; // Уже обрабатывается
}
$processing[$resourceId] = true;
// Получаем данные из таблицы PageBlocks
$c = $modx->newQuery('pbBlockValue');
$c->where(array(
'model_id' => $resourceId,
'values:!=' => '',
'published' => 1,
'deleted' => 0
));
$blocks = $modx->getCollection('pbBlockValue', $c);
if (!empty($blocks)) {
$textContent = '';
// Проходим по всем блокам и извлекаем текст
foreach ($blocks as $block) {
$jsonContent = $block->get('values');
if (!empty($jsonContent)) {
$blockData = json_decode($jsonContent, true);
if (is_array($blockData) && json_last_error() === JSON_ERROR_NONE) {
extractTextFromBlock($blockData, $textContent);
}
}
}
// Удаляем лишние пробелы
$textContent = preg_replace('/\s+/', ' ', trim($textContent));
// Устанавливаем значение TV БЕЗ save() - сохранится автоматически
$resource->setTVValue('pb_search_index', $textContent);
}
// Снимаем блокировку
unset($processing[$resourceId]);
/**
* Рекурсивная функция для извлечения текста из структуры блоков
*/
function extractTextFromBlock($data, &$text) {
// Технические поля, которые не нужно индексировать
$skipFields = array('url', 'href', 'link', 'width', 'height', 'size', 'file',
'icon_class', 'color', 'classes', 'attr', 'type',
'published', 'disabled', 'target_blank', 'menuindex',
'rank', 'id', 'resource', 'parent', 'limit', 'image');
if (is_array($data)) {
foreach ($data as $key => $value) {
// Пропускаем технические поля
if (in_array($key, $skipFields, true)) {
continue;
}
if (is_string($value) && !empty(trim($value))) {
$cleanText = strip_tags($value);
$cleanText = html_entity_decode($cleanText, ENT_QUOTES, 'UTF-8');
if (!empty(trim($cleanText)) && mb_strlen($cleanText) > 2) {
$text .= $cleanText . ' ';
}
} elseif (is_array($value)) {
extractTextFromBlock($value, $text);
}
}
} elseif (is_string($data) && !empty(trim($data))) {
$cleanText = strip_tags($data);
$cleanText = html_entity_decode($cleanText, ENT_QUOTES, 'UTF-8');
if (!empty(trim($cleanText)) && mb_strlen($cleanText) > 2) {
$text .= $cleanText . ' ';
}
}
}
Ключевые моменты:
- нет вызова
$resource->save()внутри события — так избегается бесконечное сохранение; - используется
static $processing, чтобы не обрабатывать один и тот же ресурс дважды в рамках одного запроса; - из JSON фильтруются технические поля (url, href, размеры изображений и т.п.), а текст очищается от HTML.
После сохранения любого ресурса с PageBlocks TV pb_search_index должно заполняться автоматически.
Шаг 3. Массовая переиндексация существующих ресурсов (через Console)
Для уже созданных страниц плагин не выполнится, пока их не пересохранить. Делать это руками не хочется, поэтому гораздо удобнее один раз прогнать консольный код, который:
- найдёт все ресурсы, у которых есть блоки PageBlocks;
- соберёт их текст;
- заполнит TV
pb_search_indexнапрямую через SQL.
Этот код удобно запускать через компонент Console в MODX Manager.
Код для Console
Откройте Console, вставьте:
<?php
/**
* Переиндексация PageBlocks через Console
*/
set_time_limit(300);
// Получаем TV
$tv = $modx->getObject('modTemplateVar', ['name' => 'pb_search_index']);
if (!$tv) {
return "❌ TV поле pb_search_index не найдено!";
}
$tvId = $tv->get('id');
$tvTable = $modx->getTableName('modTemplateVarResource');
// Получаем ресурсы с PageBlocks
$sql = "SELECT DISTINCT model_id
FROM {$modx->getTableName('pbBlockValue')}
WHERE deleted = 0 AND published = 1
ORDER BY model_id";
$stmt = $modx->prepare($sql);
$stmt->execute();
$resourceIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo "Найдено ресурсов: " . count($resourceIds) . "\n\n";
$processed = 0;
$skipFields = ['url', 'href', 'link', 'width', 'height', 'size', 'file',
'icon_class', 'color', 'classes', 'attr', 'type',
'published', 'disabled', 'target_blank', 'menuindex',
'rank', 'id', 'resource', 'parent', 'limit', 'image'];
foreach ($resourceIds as $resourceId) {
// Получаем блоки
$c = $modx->newQuery('pbBlockValue');
$c->where([
'model_id' => $resourceId,
'values:!=' => '',
'published' => 1,
'deleted' => 0
]);
$blocks = $modx->getCollection('pbBlockValue', $c);
if (empty($blocks)) {
echo "⚠ Ресурс #{$resourceId}: нет блоков\n";
continue;
}
$textContent = '';
// Извлекаем текст
foreach ($blocks as $block) {
$jsonContent = $block->get('values');
if (empty($jsonContent)) continue;
$blockData = json_decode($jsonContent, true);
if (!is_array($blockData) || json_last_error() !== JSON_ERROR_NONE) continue;
array_walk_recursive($blockData, function($value, $key) use (&$textContent, $skipFields) {
if (in_array($key, $skipFields, true)) return;
if (is_string($value) && mb_strlen(trim($value)) > 2) {
$cleaned = strip_tags($value);
$cleaned = html_entity_decode($cleaned, ENT_QUOTES, 'UTF-8');
$cleaned = trim($cleaned);
if (!empty($cleaned)) {
$textContent .= $cleaned . ' ';
}
}
});
}
$textContent = preg_replace('/\s+/', ' ', trim($textContent));
if (empty($textContent)) {
echo "⚠ Ресурс #{$resourceId}: текст не извлечён\n";
continue;
}
// Проверяем существование записи
$checkSql = "SELECT id FROM {$tvTable} WHERE contentid = ? AND tmplvarid = ?";
$checkStmt = $modx->prepare($checkSql);
$checkStmt->execute([$resourceId, $tvId]);
$exists = $checkStmt->fetchColumn();
if ($exists) {
// UPDATE
$updateSql = "UPDATE {$tvTable} SET value = ? WHERE contentid = ? AND tmplvarid = ?";
$updateStmt = $modx->prepare($updateSql);
$result = $updateStmt->execute([$textContent, $resourceId, $tvId]);
} else {
// INSERT
$insertSql = "INSERT INTO {$tvTable} (contentid, tmplvarid, value) VALUES (?, ?, ?)";
$insertStmt = $modx->prepare($insertSql);
$result = $insertStmt->execute([$resourceId, $tvId, $textContent]);
}
if ($result) {
$processed++;
echo "✓ Ресурс #{$resourceId}: " . mb_strlen($textContent) . " символов\n";
} else {
echo "❌ Ресурс #{$resourceId}: ошибка сохранения\n";
}
}
// Очищаем кэш
$modx->cacheManager->refresh();
return "\n\n✅ Готово! Обработано: {$processed} из " . count($resourceIds);
Запустите, дождитесь окончания и выборочно проверьте несколько ресурсов: в TV pb_search_index должен появиться объединённый текст из всех блоков.
Шаг 4. Страница поиска на Fenom с учётом PageBlocks
Теперь, когда индекс PageBlocks лежит в TV, можно добавить его в where в pdoPage и использовать в шаблоне результатов. Логика похожа на урок с web-revenue: форма поиска → отдельная страница /search/ → Fenom-код с pdoPage для вывода результатов.
Форма поиска (в шапке)
Откройте базовый шаблон (base.tpl) найдите в нем форму поиска:
<form role="search" action="page-search-results.html" method="get">
<div class="simple-search input-group">
<input class="form-control text-1" id="headerSearch" name="q" type="search" value="" placeholder="Search...">
<button class="btn" type="submit" aria-label="Search">
<i class="fas fa-search header-nav-top-icon"></i>
</button>
</div>
</form>
И отредактируйте ее вот так:
<form role="search" action="/search/" method="get">
<div class="simple-search input-group">
<input class="form-control text-1"
id="headerSearch"
name="search"
type="search"
value="{$.get.search | escape}"
placeholder="Поиск по сайту...">
<button class="btn" type="submit" aria-label="Search">
<i class="fas fa-search header-nav-top-icon"></i>
</button>
</div>
</form>
Изменения:
action="/search/"вместоpage-search-results.htmlname="search"вместоname="q"value="{$.get.search | escape}"для сохранения запроса
Форма поиска (в сайдбаре блога)
Откройте чанк elements/chunks/blog/sidebar.tpl и найдите там форму поиска
<form action="page-search-results.html" method="get">
<div class="input-group mb-3 pb-1">
<input class="form-control box-shadow-none text-1 border-0 bg-color-grey" placeholder="Search..." name="s" id="s" type="text">
<button type="submit" class="btn bg-color-grey text-1 p-2"><i class="fas fa-search m-2"></i></button>
</div>
</form>
И отредактируйте ее вот так:
<form action="/search/" method="get">
<div class="input-group mb-3 pb-1">
<input class="form-control box-shadow-none text-1 border-0 bg-color-grey"
placeholder="Поиск по блогу..."
name="search"
id="s"
type="text"
value="{$.get.search | escape}">
<button type="submit" class="btn bg-color-grey text-1 p-2">
<i class="fas fa-search m-2"></i>
</button>
</div>
</form>
Изменения:
action="/search/"name="search"вместоname="s"value="{$.get.search | escape}"
Страница «Результаты поиска»
Создайте ресурс:
- Заголовок: «Результаты поиска»
- Псевдоним:
search - Шаблон: любой Fenom-шаблон/статика
- «Скрыть из меню», «Исключить из карты сайта» — по желанию.
В поле содержимого (Fenom) вставьте:
{set $query = $.get.search | escape}
{if $query?}
<h1>Результаты поиска по запросу "{$query}"</h1>
{set $results = '!pdoPage' | snippet : [
'parents' => 0,
'includeContent'=> 1,
'includeTVs' => 'pb_search_index,img',
'processTVs' => 1,
'tvPrefix' => '',
'limit' => 10,
'tpl' => '@FILE chunks/search/searchResult.tpl',
'where' => [
'pagetitle:LIKE' => '%' ~ $query ~ '%',
'OR:introtext:LIKE' => '%' ~ $query ~ '%',
'OR:content:LIKE' => '%' ~ $query ~ '%',
'OR:pb_search_index:LIKE' => '%' ~ $query ~ '%'
] | toJSON
]}
{if $results?}
{$results}
{$page.nav}
{else}
<p>По запросу "{$query}" ничего не найдено.</p>
{/if}
{else}
<p>Введите запрос в форму поиска.</p>
{/if}
Здесь важные моменты:
includeTVs => 'pb_search_index,img'— чтобы TV было доступно в чанке результатов (например, для диагностики или подсветки);- в
whereдобавленOR:pb_search_index:LIKE, который как раз и даёт поиск по тексту PageBlocks; tplуказывает на внешний файл-чанк Fenom для оформления карточки результата.
Пример чанка chunks/search/searchResult.tpl
<div class="row border-bottom mb-3 pb-3">
<div class="col-12">
<h3><a href="{$uri}">{$pagetitle}</a></h3>
{if $introtext}
<p>{$introtext}</p>
{else}
<p>{$content | strip_tags | truncate : 200 : '…'}</p>
{/if}
</div>
</div>
При желании можно сделать подсветку фрагмента из pb_search_index или добавить картинку из TV img, если она у вас уже используется в других чанках.
Шаг 5. Тестирование и отладка
Для проверки:
- Выберите уникальное слово из блока PageBlocks (которого нет в
pagetitle/introtext/content). - Убедитесь, что ресурс сохранён после установки плагина, либо переиндексирован через Console.
- В форме поиска введите это слово.
- Страница
searchдолжна показать нужный ресурс.
Если ресурс не находится:
- проверьте значение TV
pb_search_indexу этого ресурса; - временно выведите
{$pb_search_index}в чанке результата под заголовком, чтобы убедиться, что индекс действительно содержит нужный текст; - проверьте
whereв pdoPage — нет ли опечатки в имени TV.
Бонус: подсветка найденного текста (Highlighting)
Когда поиск находит страницу по содержимому PageBlocks, пользователь может не понять, где именно встретилось слово, если мы выведем просто introtext. Давайте сделаем «умный сниппет», который вырезает цитату вокруг найденного слова.
Создайте сниппет SearchHighlight:
<?php
/**
* SearchHighlight
* Вырезает кусок текста вокруг искомой фразы и подсвечивает её.
*
* @param string $text Полный текст (content или индекс PageBlocks)
* @param string $query Поисковый запрос
* @param int $length Длина возвращаемого фрагмента (символов)
* @param string $tag Тег для подсветки (по умолчанию strong)
*/
$text = strip_tags($text); // На всякий случай чистим
$query = trim($query);
$length = $modx->getOption('length', $scriptProperties, 200);
$tag = $modx->getOption('tag', $scriptProperties, 'strong');
$ellipsis = '...';
if (empty($query)) {
return mb_substr($text, 0, $length) . $ellipsis;
}
// Ищем позицию первого вхождения (без учета регистра)
$pos = mb_stripos($text, $query);
if ($pos === false) {
// Если слова нет в тексте (нашли по заголовку), отдаем начало
return mb_substr($text, 0, $length) . $ellipsis;
}
// Вычисляем начало и конец обрезки
$start = max(0, $pos - ($length / 2));
$end = $start + $length;
// Обрезаем
$snippet = mb_substr($text, $start, $length);
// Если отрезали не с начала, добавим троеточие
if ($start > 0) {
$snippet = $ellipsis . $snippet;
}
// Если текст продолжается дальше, добавим троеточие в конец
if ($end < mb_strlen($text)) {
$snippet .= $ellipsis;
}
// Подсвечиваем искомое слово (сохраняя регистр оригинала)
// Используем preg_replace для регистронезависимой замены
$snippet = preg_replace(
'/(' . preg_quote($query, '/') . ')/iu',
'<' . $tag . ' class="text-primary">$1</' . $tag . '>',
$snippet
);
return $snippet;
Обновляем чанк searchResult.tpl
Теперь используем этот сниппет в чанке вывода результатов. Логика такая:
- Если есть совпадение в
pb_search_index, показываем фрагмент оттуда. - Иначе показываем стандартный
introtextили началоcontent.
<div class="row border-bottom mb-3 pb-3">
<div class="col-12">
<h3><a href="{$uri}">{$pagetitle}</a></h3>
<p>
{* Определяем, где искать текст для превью *}
{var $sourceText = $_pls['tv.pb_search_index'] ?: $_pls['content']}
{* Вызываем наш сниппет *}
{'!SearchHighlight' | snippet : [
'text' => $sourceText,
'query' => $.get.search,
'length' => 250
]}
</p>
<div class="text-muted text-1">
<time datetime="{$publishedon | date : 'Y-m-d'}">
{$publishedon | date : 'd.m.Y'}
</time>
</div>
</div>
</div>
Итог
Таким образом, получился простой и расширяемый поиск по сайту:
- контент из PageBlocks подтягивается из таблицы
modx_pb_block_values, парсится из JSON и складывается в отдельное TV; - при сохранении ресурса индекс PageBlocks обновляется автоматически, без дополнительного кода в шаблонах;
- поиск (через pdoPage и Fenom) ищет одновременно в
pagetitle,introtext,contentи в индексе PageBlocks, что делает результаты гораздо полнее для лендингов и «конструкторных» страниц.
Дальше можно развивать решение: добавлять полнотекстовый поиск по этому TV, интегрировать с mSearch2 или выносить индексацию в очередь / cron для тяжёлых сайтов.







