
JavaScript API
Архитектура JavaScript слоёная. Доступны два режима: полный стек с UI (сценарий сниппета mSearchForm) или headless для собственных интерфейсов на React, Vue, Svelte и т. д.
Архитектура
window.MSearchApiClient — HTTP-клиент для API
↓
window.MSearchAPI — фасад поискового API
↓
window.msearchHooks — система хуков (опционально)
↓
window.msearch — headless точка входа
↓
window.MSearchUI — UI-слой автокомплита (опционально)
↓
msearch.js — автоинициализация по data-msearch-form (опционально)Напрямую нужен только window.msearch (и window.msearchHooks для расширения). MSearchApiClient и MSearchAPI — внутренние слои, обращаться к ним вручную не требуется.
Публичный API endpoint
Все JS-запросы идут на assets/components/msearch/api.php.
Контракт
API принимает от клиента минимальный набор параметров — см. таблицу ниже.
Параметры отрисовки (tpl, имя чанка), limit, имя внешнего сниппета element и elementProperties хранятся в кэше MODX и подбираются по form. Это закрывает SSTI-вектор: клиент не может подсунуть произвольный чанк или сниппет.
Параметры
| Параметр | Тип | Описание | Где применим |
|---|---|---|---|
query | string | Поисковый запрос | /search, /search/suggest |
offset | int | Смещение для пагинации, default 0 | /search |
form | string | 12-символьный formId сниппет-формы. По нему сервер достаёт tpl/limit/element из кэша | /search, /search/suggest |
ctx | string | Контексты через запятую (web, web,shop). Если в кэше формы задан непустой ctx — query-значение игнорируется | /search, /search/suggest |
Поиск
GET /assets/components/msearch/api.php?route=/search&query=текст&offset=0&form=abc123def456Ответ:
{
"success": true,
"total": 42,
"limit": 10,
"offset": 0,
"query": "текст",
"items": [
{
"id": 123,
"url": "https://site.com/page/alias",
"type": "resource",
"html": "<div class=\"mse-result\">...</div>",
"weight": 45
}
]
}Автодополнение
GET /assets/components/msearch/api.php?route=/search/suggest&query=те&form=abc123def456Ответ:
{
"success": true,
"items": [
{
"id": 123,
"url": "https://site.com/page/alias",
"type": "product",
"html": "<a href=\"...\" class=\"mse-suggest-item...\">...</a>",
"weight": 15
}
]
}Каждый элемент также несёт поле pagetitle — чистый заголовок без <mark>. Это служебное поле для админ-превью; фронтенд его игнорирует и читает html.
Поле html
Сервер заранее отрисовывает каждый результат через pdoTools::getChunk по шаблону из сниппет-формы (&tpl=). Клиенту приходит готовый HTML — для вставки через innerHTML.
Внутри чанка плейсхолдеры зависят от адаптера:
type = 'resource'(проходит черезResourceAdapter) —pagetitle,longtitle,description,introи т. д.type = 'product'(черезMsProductAdapter, при наличии miniShop3) —price_display,old_price_display,thumb,article,has_discount.
Headless без сниппета
Если запрос идёт без form (например, SPA на другом домене вызывает API напрямую, без сниппета mSearchForm), сервер использует значения по умолчанию: limit=5 для suggest и limit=10 для search, tpl из адаптера, ctx — из query-параметра, если указан, иначе без фильтра. html в ответе всё равно будет — отрисовка идёт на сервере.
Нет «сырых данных»
Endpoint всегда возвращает HTML в поле html. Чтобы получить структурированные данные на стороне SPA — придётся пройти через сниппет на странице (он напишет formId в кэш с минимальным чанком, который выводит JSON), либо подключить серверное расширение через событие mseOnAfterSearch и формировать свой ответ.
Пограничные случаи
Endpoint отвечает 200 OK даже на некорректный ввод — кодов ошибок (4xx) в этих случаях нет:
| Ситуация | Ответ |
|---|---|
Запрос короче mse_suggest_min_query_length | {"success":true,"items":[]} |
Пустой query | {"success":true,"items":[]} |
Неизвестный form (нет в кэше) | Откат к конфигурации по умолчанию — поиск выполняется, items заполнены |
То есть неверный formId ошибку не вызывает: сервер просто берёт значения по умолчанию, как в разделе «Headless без сниппета». Проверяйте success и длину items, а не HTTP-код.
Headless-режим
window.msearch предоставляет программный API без привязки к DOM. Подходит для React, Vue, Svelte и любых собственных интерфейсов.
Подключение
<script src="/assets/components/msearch/js/web/core/ApiClient.js"></script>
<script src="/assets/components/msearch/js/web/core/SearchAPI.js"></script>
<script src="/assets/components/msearch/js/web/modules/hooks.js"></script>
<script src="/assets/components/msearch/js/web/msearch.headless.js"></script>Конфигурация
<script>
window.msearchConfig = {
apiUrl: '/assets/components/msearch/api.php',
ctx: 'web',
autoInit: true
};
</script>Авто-инициализация после загрузки DOM и событие msearch:ready срабатывают только при наличии объекта window.msearchConfig (и autoInit !== false). Без msearchConfig объект window.msearch всё равно существует, но инициализируется лениво — при первом вызове search()/suggest() или явном init().
ctx в msearchConfig — публичный фильтр по контексту; передаётся в каждый запрос, если в options вызова не указан явно.
Использование
// Поиск
const result = await msearch.search('смартфон', { offset: 0 });
console.log(result.total); // 42
console.log(result.items); // [{id, url, type, html, weight}, ...]
// Автодополнение
const suggestions = await msearch.suggest('см');
console.log(suggestions.items); // [{id, url, type, html, weight}, ...]Опции вызова
| Опция | Применима к | Описание |
|---|---|---|
form | search, suggest | formId, связывающий с серверной конфигурацией сниппета mSearchForm |
ctx | search, suggest | Строка контекстов через запятую или массив. Перебивает msearchConfig.ctx |
offset | search | Смещение пагинации |
Ручная инициализация
await msearch.init({
apiUrl: '/assets/components/msearch/api.php',
ctx: 'web'
});Уничтожение
Для SPA, где компонент поиска монтируется и размонтируется, доступен msearch.destroy() — сбрасывает HTTP-клиент, API и хуки, переводит объект в неинициализированное состояние (следующий search()/suggest() инициализирует заново).
msearch.destroy();События DOM
| Событие | Описание |
|---|---|
msearch:ready | Headless API инициализирован |
msearch:ui:ready | UI-слой инициализирован (если подключён) |
document.addEventListener('msearch:ready', (e) => {
const msearch = e.detail.msearch;
// API готов к использованию
});Система хуков
Хуки позволяют расширять поведение поиска на стороне клиента — модифицировать запросы, обрабатывать результаты, логировать.
Доступные хуки
| Хук | Описание |
|---|---|
beforeSearch | Перед отправкой поискового запроса |
afterSearch | После получения результатов поиска |
beforeSuggest | Перед запросом автодополнения |
afterSuggest | После получения подсказок |
onError | При ошибке любого запроса |
Подписка
// Добавить хук (возвращает функцию отписки)
const unsubscribe = msearchHooks.add('beforeSearch', function(context) {
console.log('Ищем:', context.query);
// Доступно для модификации:
// context.query — поисковый запрос
// context.options — параметры запроса (ctx, offset, form)
// context.cancelled = true — отменить запрос
// context.result — подставленный результат (если cancelled)
});
// Отписаться
unsubscribe();Методы msearchHooks
| Метод | Описание |
|---|---|
add(name, fn, priority = 10) | Подписать колбэк на хук; возвращает функцию отписки |
remove(name, fn) | Снять конкретный колбэк с хука |
clear(name) | Удалить все обработчики хука; без аргумента — очистить все хуки |
has(name) | true, если у хука есть хотя бы один колбэк |
count(name) | Число колбэков, подписанных на хук |
Приоритеты
Хук с меньшим приоритетом выполняется раньше.
msearchHooks.add('afterSearch', analyticsHook, 5); // первый
msearchHooks.add('afterSearch', transformHook, 10); // второй
msearchHooks.add('afterSearch', renderHook, 20); // третийПримеры
Логирование запросов
msearchHooks.add('afterSearch', function(context) {
if (context.result && context.result.total > 0) {
console.log(`Найдено ${context.result.total} по запросу "${context.query}"`);
}
});Принудительный фильтр по контексту
msearchHooks.add('beforeSearch', function(context) {
context.options.ctx = 'web';
});Отмена запроса
msearchHooks.add('beforeSearch', function(context) {
if (context.query.length < 3) {
context.cancelled = true;
context.result = { success: true, items: [], total: 0 };
}
});Обработка ошибок
msearchHooks.add('onError', function(context) {
showNotification('Ошибка поиска: ' + context.error.message);
});UI-слой
UI-слой MSearchUI добавляет автодополнение к формам с атрибутом data-msearch-form. Подключается автоматически через сниппет mSearchForm при autocomplete=1.
Защита от гонки
При быстром вводе UI отбрасывает устаревшие ответы — каждый запрос получает порядковый номер (requestSeq), и при возврате сравнивается с текущим. Ответы более раннего запроса игнорируются, новый запрос отмены не делает — только consumer-side фильтрация.
Ручное создание
const instance = MSearchUI.create(document.querySelector('#my-form'), {
minQueryLength: 2,
debounceDelay: 300,
formId: 'abc123def456'
});Опции
| Опция | Default | Описание |
|---|---|---|
inputSelector | .mse-input | CSS-селектор поля ввода внутри формы |
resultsSelector | .mse-results | CSS-селектор контейнера результатов (зарезервировано) |
autocomplete | true | Включить автодополнение |
minQueryLength | 2 | Минимальная длина запроса на клиенте. По умолчанию берётся из настройки mse_suggest_min_query_length (сниппет прокидывает её в window.msearchConfig); data-min-query-length переопределяет |
debounceDelay | 300 | Задержка debounce, мс |
connectorUrl | '' | URL API endpoint (обычно подхватывается из data-connector-url) |
formId | '' | formId сниппета (обычно подхватывается из data-form-id) |
Data-атрибуты формы
Опции UI можно задавать через data-атрибуты непосредственно на форме:
| Атрибут | Описание |
|---|---|
data-msearch-form | Маркер для автоинициализации msearch.js |
data-connector-url | URL API endpoint |
data-form-id | 12-символьный formId, связывающий форму с серверной конфигурацией |
data-autocomplete | true / false — включить автодополнение |
data-min-query-length | Переопределяет minQueryLength |
data-debounce-delay | Переопределяет debounceDelay (мс) |
Где взять formId
formId формируется сниппетом mSearchForm и попадает в чанк формы через плейсхолдер {$formId}. В дефолтном чанке mSearch.form он уже прописан в data-form-id. При построении формы вручную через MSearchUI.create() без сниппета — formId не нужен, сервер использует дефолтные параметры.
CSS-классы
Базовые
| Класс | Элемент |
|---|---|
.mse-form | Форма поиска |
.mse-input | Поле ввода |
.mse-button | Кнопка отправки |
.mse-autocomplete | Dropdown-список подсказок (<ul>) |
.mse-autocomplete.active | Открытый dropdown |
.mse-autocomplete-item | Элемент подсказки (<li>) |
.mse-autocomplete-item.active | Выделенный элемент (стрелками) |
Строка подсказки (внутри .mse-autocomplete-item)
Эти классы используются в дефолтных чанках mSearch.suggest.row:
| Класс | Назначение |
|---|---|
.mse-suggest-item | Корневая ссылка <a> внутри <li> |
.mse-suggest-item--resource | Модификатор для обычных ресурсов |
.mse-suggest-item--product | Модификатор для товаров msProduct |
.mse-suggest-thumb | Превью товара (<img>) |
.mse-suggest-body | Контейнер текстовых полей |
.mse-suggest-title | Заголовок |
.mse-suggest-article | Артикул товара |
.mse-suggest-intro | Сниппет текста (для ресурсов) |
.mse-suggest-price | Контейнер цены |
.mse-suggest-price-current | Текущая цена |
.mse-suggest-price-old | Старая цена (зачёркнута, при наличии скидки) |
.mse-highlight | Подсветка совпадений в тексте (формируется на сервере как <mark>) |
Управление экземплярами
// Получить экземпляр по id формы
const instance = MSearchUI.get('my-form-id');
// Уничтожить экземпляр
MSearchUI.destroy('my-form-id');Примеры интеграции
Vue 3
<script src="/assets/components/msearch/js/web/core/ApiClient.js"></script>
<script src="/assets/components/msearch/js/web/core/SearchAPI.js"></script>
<script src="/assets/components/msearch/js/web/modules/hooks.js"></script>
<script src="/assets/components/msearch/js/web/msearch.headless.js"></script>import { ref, onMounted } from 'vue';
export default {
setup() {
const query = ref('');
const results = ref([]);
const total = ref(0);
async function doSearch() {
if (query.value.length < 2) return;
const data = await window.msearch.search(query.value, { ctx: 'web' });
results.value = data.items;
total.value = data.total;
}
return { query, results, total, doSearch };
}
};items[].html — готовый HTML, поэтому во Vue выводите его через v-html:
<div v-for="item in results" :key="item.id" v-html="item.html" />React (минимальный пример)
function Search() {
const [query, setQuery] = useState('');
const [items, setItems] = useState([]);
useEffect(() => {
if (query.length < 2) { setItems([]); return; }
const t = setTimeout(() => {
window.msearch.suggest(query, { ctx: 'web' })
.then(res => setItems(res.items));
}, 300);
return () => clearTimeout(t);
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>
{items.map(it => (
<li key={it.id} dangerouslySetInnerHTML={{ __html: it.html }} />
))}
</ul>
</div>
);
}CORS
Публичный API (api.php) по умолчанию разрешает только same-origin запросы. Для кросс-доменных запросов укажите разрешённый Origin в системной настройке mse_cors_origin — см. Системные настройки → mse_cors_origin.
В headless-режиме без сниппета mSearchForm cookies не нужны (formId не передаётся, серверная конфигурация не используется) — стандартный CORS работает.
Безопасность контракта
- Клиент не может задать имя чанка, имя сниппета (
element),limitилиelementProperties— эти параметры приходят только из серверного кэша поformId. ctx— публичный фильтр по столбцуcontext_key, безопасен для приёма от клиента.Highlighterэкранирует входной текст (htmlspecialchars) перед обёрткой в<mark>— полеhtmlHTML-safe, вставка черезinnerHTML/v-html/dangerouslySetInnerHTMLне открывает XSS.- formId валидируется по regex
^[a-f0-9]{12}$— попытка передать../../etc/passwdили скрипт отбрасывается.
