Skip to content

feat(web-api): seo-блок для product/category get - #599

Merged
biz87 merged 2 commits into
betafrom
feat/issue-567-public-seo-block
Sep 13, 2026
Merged

biz87 merged 2 commits into
betafrom
feat/issue-567-public-seo-block

Conversation

@Ibochkarev

Copy link
Copy Markdown
Member

Описание

В ответах GET /api/v1/product/get/{id} и GET /api/v1/category/get/{id} появляется объект seo с title, description, canonical, robots и og.{title,description,image,type}. Nuxt SSR может собрать <title>, meta description, canonical и базовый Open Graph без своей угадайки по полям ресурса.

Плоские поля (pagetitle, longtitle, description, uri, image/thumb) не меняются. product/list, category/list и category/tree полный seo не отдают.

Тип изменений

  • Новая функциональность (non-breaking change)

Связанные Issues

Closes #567

Как это было протестировано?

Локальный CI-гейт (без полной установки MODX/MySQL):

cd core/components/minishop3
php -l src/Services/Seo/PublicSeoBuilder.php
php -l src/Services/Seo/PublicSeoService.php
composer test:smoke
composer ci:php
composer stan
Команда Результат
php -l (затронутые PHP) exit 0
composer test:smoke 88 smoke, exit 0
composer ci:php PHPUnit 258, exit 0
composer stan OK, exit 0
  • Ручное тестирование
  • Автоматические тесты (composer ci:php / composer test, npm run lint:ci, composer stan / GitHub Actions CI)
  • Тестирование на разных версиях PHP/MODX

Конфигурация тестирования:

  • MiniShop3: ветка feat/issue-567-public-seo-block от beta
  • MODX: stubs / без live install
  • PHP: 8.4.17

Скриншоты (если применимо)

Не применимо (JSON API).

Чеклист

  • Код соответствует стилю проекта
  • Добавлены/обновлены комментарии в сложных местах
  • Изменения не ломают существующую функциональность
  • Лексиконы добавлены на двух языках (ru/en) — новых ключей нет
  • PHPStan проходит без новых ошибок (composer stan / CI job PHPStan)
  • ESLint проходит без ошибок (npm run lint:ci для Vue) — Vue не трогали
  • Обновлён CHANGELOG.md (для значимых изменений) — запись на релизе

Дополнительные заметки

Правила derivation: title ← непустой longtitle, иначе pagetitle. description ← непустой description, иначе introtext. canonical и og.image — absolute URL из site_url контекста (?context= или context_key ресурса) + relative path. robots для уже публичных сущностей: index,follow. og.type: product / website.

include_seo=0 на get убирает ключ seo. Default на get — включено.

Опциональный хук msOnGetPublicSeo: плагин патчит $modx->event->returnedValues['seo']. Патч только title зеркалится в og.title, пока плагин сам не задал og.title. Whitelist отбрасывает неизвестные ключи и не-скаляры. TV map ms3_public_seo_tv_map в этот PR не входит.

Событие появится в Manager после rebuild/upgrade пакета.

Follow-up: get по uri/alias, multi-size og из галереи (#566), include_seo на list.

@biz87

biz87 commented Aug 18, 2026

Copy link
Copy Markdown
Member

Влил #597 (facets) — этот PR теперь конфликтует с beta (mergeStateStatus: DIRTY), нужен ребейз.

Хорошая новость: конфликт тривиальный, всего два файла и чистый union — обе стороны регистрируют свой сервис в одном месте:

src/ServiceRegistry.php          ms3_public_seo  vs  ms3_product_facets
src/ServiceRegistryFactories.php ms3_public_seo  vs  ms3_product_facets

Нужны обе записи. ProductCatalogService.php смержился автоматически, там ничего решать не надо.

Проверил локально: после union-резолва всё зелёное — smoke 90, PHPUnit 273. То есть после ребейза PR готов, вопросов по содержанию нет.

Заметил, что ты добавляешь новое событие msOnGetPublicSeo в _build/elements/events.php — это правильно, реестр там единственный источник для build.php. Если планируешь описать его в доках, скажи, добавлю в events/product.md при следующей сверке.

@Ibochkarev

Copy link
Copy Markdown
Member Author

Ребейзнул на текущую beta. В реестре обе записи: ms3_product_facets и ms3_public_seo.

Локально: smoke 90, PHPUnit 273, composer stan без ошибок.

Про msOnGetPublicSeo в доках — да, имеет смысл в events/product.md при следующей сверке.

@Ibochkarev
Ibochkarev force-pushed the feat/issue-567-public-seo-block branch from 4a05882 to c870b01 Compare August 19, 2026 02:37
@AgelxNash AgelxNash mentioned this pull request Sep 6, 2026
16 tasks
AgelxNash pushed a commit to AgelxNash/MiniShop3 that referenced this pull request Sep 6, 2026
Conflict resolution: integrate with merged PR 598 gallery
(ms3_product_gallery_public + ms3_public_seo in registry,
seo attach wrapped around gallery-aware getById payload).
@AgelxNash

Copy link
Copy Markdown

Этот PR включён в тестовую интеграционную сборку всех открытых PR MiniShop3: AgelxNash/MiniShop3, ветка integration/open-prs-20260906 (28/28 открытых).

Сборка нужна, чтобы проверить совместимость взаимозависимых серий PR до их мержа — при последовательном слиянии они конфликтуют друг с другом. Это не ревью и не конкурирующий PR: авторство сохранено (1 PR = 1 коммит с исходным автором), ветка пересобирается по мере обновления PR.

Как вошёл в сборку: Конфликт со слитым #598 (галерея) разрешён: в ProductCatalogService::getById() объединены images[] и seo-attach; в ServiceRegistry/ServiceRegistryFactories оставлены обе записи — ms3_product_gallery_public и ms3_public_seo.

@AgelxNash

Copy link
Copy Markdown

Отличная работа! Желаю этому PR быстрого мержа и ни одного конфликта 🙌

@biz87

biz87 commented Sep 7, 2026

Copy link
Copy Markdown
Member

PR проверен и к вливанию годен — возвращаю только на ребейз.

#598 (галерея изображений) влит в beta (f89f80d2), после этого здесь появился конфликт в четырёх файлах.

Что проверено до конфликта

На ветке с домердженной актуальной на тот момент beta (мержил дважды, база двигалась): smoke 92/92, PHPUnit 293 теста / 724 assertions, --testsuite WebApi 22/22, PHPStan по всем восьми изменённым файлам — без ошибок.

Отдельно подтвердил по коду то, что было главным вопросом к этому PR:

  • Хук msOnGetPublicSeo реализован правильно — через EventGate::invokeRaw() и чтение $event['returnedValues']['seo'], а не мутацию аргумента по ссылке. Ловушка «PHP-ссылки не переживают array_merge() в конвейере свойств MODX» здесь обойдена, канал возврата верный.
  • Событие зарегистрировано в _build/elements/events.php без рассинхрона с местом вызова.
  • PublicSeoBuilder::whitelist() — жёсткий allowlist, всё лишнее от плагина отбрасывается, покрыто тестами.
  • Фильтры published=1, deleted=0 и context_key унаследованы из непронутого publicCriteria(), межконтекстной утечки нет.
  • Изменение аддитивное: include_seo=0 убирает ключ, плоские поля не меняются, list/tree блок не отдают. Встроенный фронт эти эндпоинты не дёргает вовсе.

Что нужно сделать при ребейзе

Конфликтуют:

  • core/components/minishop3/src/Controllers/Api/Web/ProductController.php
  • core/components/minishop3/src/ServiceRegistry.php
  • core/components/minishop3/src/ServiceRegistryFactories.php
  • core/components/minishop3/src/Services/Product/ProductCatalogService.php

Первые три — механический union: обе стороны добавляют свою запись рядом с одним якорем ('ms3_public_seo' против 'ms3_product_gallery_public'). Достаточно сохранить обе.

Четвёртый требует руки, а не accept-both. getById() в beta теперь выглядит так:

public function getById(int $productId, array $params = []): ?array
{
    $product = $this->findVisibleProduct($productId, $params);
    if ($product === null) {
        return null;
    }

    $options = self::stripOptionMetadata(
        $this->optionService()->loadOptionsForProduct($productId, false)
    );

    $includeImages = self::toBool($params['include_images'] ?? false);
    $images = $includeImages ? $this->loadImagesForProduct($product) : null;

    return $this->formatProduct($product, true, $options, $images);
}

То есть #598 вынес построение критериев и выборку в findVisibleProduct() и добавил четвёртый аргумент в formatProduct(). Твою привязку seo нужно переложить поверх этой версии.

Предупреждаю специально: я прогнал автослияние — git выдаёт синтаксически бессмысленный результат, код seo-привязки уезжает внутрь чужого метода loadImagesForProducts(). Это тот случай, когда конфликт разрезал логику, поэтому и возвращаю тебе, а не резолвлю сам. Сами фичи независимы — картинки и seo-блок друг другу не мешают, вопрос только в аккуратной пересборке метода.

Мелочи, на усмотрение

  • PublicSeoBuilder::absoluteUrl() строит canonical и og:image конкатенацией site_url + uri, а не через modX::makeUrl()/modContext::makeUrl(). На контексте с friendly_urls=0 реальный URL — index.php?id=X, и canonical получится нерабочим. Понимаю, что целевой потребитель — Nuxt со своим роутингом, и что uri и раньше был в публичном контракте. Но для блока, который называется SEO, неверный canonical дороже неверной внутренней ссылки — стоит либо учесть friendly_urls, либо явно записать это ограничение в описании.
  • robots всегда index,follow, поле modResource.searchable не учитывается. Ты этого и не обещал, но для полноты SEO-блока пробел заметный.
  • msOnGetPublicSeo зарегистрирован в секции // Product events, хотя используется и для категорий.
  • Событие пока нигде не задокументировано — это попадёт в чеклист синхронизации документации перед релизом, отдельной работой.

После ребейза — вливаем.

@Ibochkarev

Copy link
Copy Markdown
Member Author

Rebased onto current beta после #598 (f89f80d2).

Конфликты

  • ServiceRegistry / ServiceRegistryFactories / docblock в ProductController — union: оставлены и ms3_product_gallery_public, и ms3_public_seo
  • ProductCatalogService::getById() — руками поверх feat(web-api): галерея изображений товара (images[]) #598: findVisibleProduct → options → include_imagesformatProduct(..., $images)PublicSeoService::maybeAttachProduct(). SEO-код не попал в loadImagesForProducts()

Проверки

composer ci:php: php -l 643, smoke 93 (включая PublicSeoRoutesTest), PHPUnit 307. --filter PublicSeo 19/19.

Мелочи (friendly_urls / searchable / секция events / docs) — на follow-up, как в ревью.

@biz87

biz87 commented Sep 11, 2026

Copy link
Copy Markdown
Member

Проверил после ребейза — по существу к вливанию готов. Возвращаю снова только на ребейз.

Что подтвердилось

  • Конфликты после feat(web-api): галерея изображений товара (images[]) #598 разрешены правильно. ms3_product_gallery_public и ms3_public_seo оба зарегистрированы и резолвятся. В ProductCatalogService::getById() порядок ровно как описан: findVisibleProduct → options → include_imagesformatProduct(..., $images)PublicSeoService::maybeAttachProduct(). В loadImagesForProducts() seo-кода нет. Для категории так же.
  • Прошлые пункты не сломаны: канал возврата через returnedValues, жёсткий whitelist, фильтры публикации и контекста, include_seo=0, списки без блока.
  • Числа после мержа со свежей beta: smoke 100/100, PHPUnit 335, WebApi 24/24, --filter PublicSeo 19/19, PHPStan — 0 ошибок.

Почему снова ребейз

Только что влит #644 (get по alias/uri). Он правит те же сервисы каталога, и теперь есть один конфликт — в импортах Services/Category/CategoryCatalogService.php: #644 добавляет use ...CatalogResolve, этот PR — use ...PublicSeoService в то же место. Остальные общие файлы (ProductController, CategoryController, ProductCatalogService) сливаются автоматически — проверено заранее через git merge-tree.

Приятное следствие: resolveByLookup() из #644 возвращает результат через getById(), так что seo-блок после ребейза попадёт и в get по alias/uri без доработок. По желанию — один тест на это.

Follow-up

Этот PR закрывает #567, а в нём и в описании PR записаны пункты на потом. Чтобы они не потерялись, завёл #703: карта TV-полей, robots по searchable, canonical при friendly_urls=0 (сейчас absoluteUrl() склеивает адрес строкой), include_seo для списков, og из галереи, событие msOnGetPublicSeo в документации и в events.php. В этот PR ничего из этого тащить не нужно.

Headless SSR needs a stable title/canonical/og contract instead of
guessing longtitle vs pagetitle and joining site_url by hand.
resolveByLookup from #644 returns via getById, so product/category
get by alias or uri already include the seo block. Guard that reuse
in the PublicSeo smoke so a later shortcut cannot drop it.
@Ibochkarev
Ibochkarev force-pushed the feat/issue-567-public-seo-block branch from 869ff44 to 97ea333 Compare September 12, 2026 02:09
@Ibochkarev

Copy link
Copy Markdown
Member Author

Rebased onto current beta после #644.

Конфликт в CategoryCatalogService.php — union импортов: CatalogResolve и PublicSeoService. ProductCatalogService::getById() без изменений: findVisibleProduct → options → include_images → formatProduct(..., $images) → maybeAttachProduct().

resolveByLookup() из #644 идёт через getById(), поэтому seo есть и на get по alias/uri. Добавил smoke в PublicSeoRoutesTest.

Локально: smoke 101, --filter PublicSeo 19/19. composer ci:php упал на трёх HeadlessStorefrontErrorsTest (401/400 из-за headers already sent) — к seo-блоку не относятся.

@biz87
biz87 merged commit bdc83d8 into beta Sep 13, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request priority: medium Средний приоритет

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Web API: seo-блок для product/category (headless SSR)

3 participants