From 7071f1d62374220491ca35e2d2edfd12e88190c6 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 13 Sep 2026 21:30:42 +0600 Subject: [PATCH] feat(web-api): SEO follow-ups TV map, searchable robots, makeUrl Add ms3_public_seo_tv_map overlay, robots from searchable, canonical via makeUrl, and opt-in include_seo on list/tree. Document msOnGetPublicSeo; defer multi-size og.image (#703). --- _build/elements/events.php | 2 + _build/elements/settings.php | 5 + .../minishop3/lexicon/en/setting.inc.php | 2 + .../minishop3/lexicon/ru/setting.inc.php | 2 + .../Api/Web/CategoryController.php | 4 +- .../Controllers/Api/Web/ProductController.php | 2 +- .../Category/CategoryCatalogService.php | 39 ++- .../Product/ProductCatalogService.php | 25 +- .../src/Services/Seo/PublicSeoBuilder.php | 27 +- .../src/Services/Seo/PublicSeoService.php | 240 +++++++++++++++++- .../src/Services/Seo/PublicSeoTvMap.php | 174 +++++++++++++ .../minishop3/tests/PublicSeoRoutesTest.php | 38 ++- .../Services/Seo/PublicSeoBuilderTest.php | 12 + .../Services/Seo/PublicSeoServiceTest.php | 216 +++++++++++++++- .../Unit/Services/Seo/PublicSeoTvMapTest.php | 173 +++++++++++++ .../tests/stubs/ModTemplateVarStub.php | 16 ++ 16 files changed, 929 insertions(+), 48 deletions(-) create mode 100644 core/components/minishop3/src/Services/Seo/PublicSeoTvMap.php create mode 100644 core/components/minishop3/tests/Unit/Services/Seo/PublicSeoTvMapTest.php create mode 100644 core/components/minishop3/tests/stubs/ModTemplateVarStub.php diff --git a/_build/elements/events.php b/_build/elements/events.php index f28569dad..b994dff6e 100644 --- a/_build/elements/events.php +++ b/_build/elements/events.php @@ -83,6 +83,8 @@ 'msOnGetProductPrice', 'msOnGetProductWeight', 'msOnGetProductFields', + + // Public catalog / SEO (product get + category get/list/tree when include_seo=1) 'msOnGetPublicSeo', // msProducts snippet events (for extending with external packages) diff --git a/_build/elements/settings.php b/_build/elements/settings.php index dca0d4e57..3cfaccba1 100644 --- a/_build/elements/settings.php +++ b/_build/elements/settings.php @@ -563,6 +563,11 @@ 'xtype' => 'textfield', 'area' => 'ms3_api', ], + 'ms3_public_seo_tv_map' => [ + 'value' => '', + 'xtype' => 'textarea', + 'area' => 'ms3_api', + ], // Notifications 'ms3_telegram_bot_token' => [ diff --git a/core/components/minishop3/lexicon/en/setting.inc.php b/core/components/minishop3/lexicon/en/setting.inc.php index 18399ebc3..1c78d3668 100644 --- a/core/components/minishop3/lexicon/en/setting.inc.php +++ b/core/components/minishop3/lexicon/en/setting.inc.php @@ -275,6 +275,8 @@ $_lang['setting_ms3_rate_limit_redis_database_desc'] = 'Redis database index (default 0) when DSN is not set.'; $_lang['setting_ms3_rate_limit_memcached_servers'] = 'Rate limit Memcached servers'; $_lang['setting_ms3_rate_limit_memcached_servers_desc'] = 'Comma-separated host:port list for the memcached driver. Env: MS3_RATE_LIMIT_MEMCACHED_SERVERS. Requires ext-memcached.'; +$_lang['setting_ms3_public_seo_tv_map'] = 'Public SEO TV map'; +$_lang['setting_ms3_public_seo_tv_map_desc'] = 'Optional JSON map of allowlisted seo keys to TV references for Web API catalog payloads, e.g. {"title":"tv.seo_title","robots":"tv.robots"}. Invalid JSON is ignored. Applies to product/category get and list/tree when include_seo=1.'; // Notifications $_lang['setting_ms3_telegram_bot_token'] = 'Telegram bot token'; diff --git a/core/components/minishop3/lexicon/ru/setting.inc.php b/core/components/minishop3/lexicon/ru/setting.inc.php index 47edb3dbf..dae59ee43 100644 --- a/core/components/minishop3/lexicon/ru/setting.inc.php +++ b/core/components/minishop3/lexicon/ru/setting.inc.php @@ -275,6 +275,8 @@ $_lang['setting_ms3_rate_limit_redis_database_desc'] = 'Номер БД Redis (0 по умолчанию), если DSN не задан.'; $_lang['setting_ms3_rate_limit_memcached_servers'] = 'Memcached servers (rate limit)'; $_lang['setting_ms3_rate_limit_memcached_servers_desc'] = 'Список серверов host:port через запятую для memcached-драйвера. Env: MS3_RATE_LIMIT_MEMCACHED_SERVERS. Требует ext-memcached.'; +$_lang['setting_ms3_public_seo_tv_map'] = 'Карта TV для публичного SEO'; +$_lang['setting_ms3_public_seo_tv_map_desc'] = 'Необязательный JSON: allowlisted-ключи seo → TV, например {"title":"tv.seo_title","robots":"tv.robots"}. Некорректный JSON игнорируется. Для product/category get и list/tree при include_seo=1.'; // Notifications $_lang['setting_ms3_telegram_bot_token'] = 'Токен Telegram бота'; diff --git a/core/components/minishop3/src/Controllers/Api/Web/CategoryController.php b/core/components/minishop3/src/Controllers/Api/Web/CategoryController.php index 1dd7c6254..c9686d934 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/CategoryController.php +++ b/core/components/minishop3/src/Controllers/Api/Web/CategoryController.php @@ -107,7 +107,7 @@ public function resolve(array $params = []): Response * GET /api/v1/category/list * * Query: parent, limit, offset|page, sort, dir, context, - * include_hidden, include_content + * include_hidden, include_content, include_seo (default 0) * * @param array $params */ @@ -125,7 +125,7 @@ public function getList(array $params = []): Response /** * GET /api/v1/category/tree * - * Query: parent, depth, context, include_hidden, sort, dir + * Query: parent, depth, context, include_hidden, sort, dir, include_seo (default 0) * * @param array $params */ diff --git a/core/components/minishop3/src/Controllers/Api/Web/ProductController.php b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php index 89ec3fa87..89a4a5329 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/ProductController.php +++ b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php @@ -111,7 +111,7 @@ public function resolve(array $params = []): Response * Query: parent|category, parents, nested, price_min, price_max, in_stock, stock_min, * vendor_id, new, popular, favorite, options (JSON), * limit, offset|page, sort, dir, query, context, include_options, include_content, - * include_images (0|1, default 0, cap 10 files per item) + * include_images (0|1, default 0, cap 10 files per item), include_seo (default 0) * * @param array $params Route + query params (Router merges $_GET) */ diff --git a/core/components/minishop3/src/Services/Category/CategoryCatalogService.php b/core/components/minishop3/src/Services/Category/CategoryCatalogService.php index ac8da139b..1be83180b 100644 --- a/core/components/minishop3/src/Services/Category/CategoryCatalogService.php +++ b/core/components/minishop3/src/Services/Category/CategoryCatalogService.php @@ -36,6 +36,7 @@ class CategoryCatalogService 'publishedon', 'createdon', 'editedon', + 'searchable', ]; /** @var list */ @@ -128,7 +129,7 @@ public static function buildTreeNodes( /** * Query: context, include_hidden, include_content, include_breadcrumbs, - * include_children, include_seo (default 1). List/tree omit seo. + * include_children, include_seo (default 1). List/tree: include_seo (default 0). * * @param array $params * @return array|null @@ -155,18 +156,22 @@ public function getById(int $categoryId, array $params = []): ?array } if (CatalogQuery::toBool($params['include_children'] ?? false)) { - $payload['children'] = $this->listDirectChildrenPayloads( + $children = $this->listDirectChildrenPayloads( $categoryId, $params, $includeHidden, CatalogQuery::resolveLimit($params), ); + if (CatalogQuery::resolveBool($params, 'include_seo', true)) { + $children = $this->publicSeo()->attachSeoToCategoryList( + $children, + array_merge($params, ['include_seo' => 1]), + ); + } + $payload['children'] = $children; } - /** @var PublicSeoService $seo */ - $seo = $this->modx->services->get('ms3_public_seo'); - - return $seo->maybeAttachCategory($payload, $params); + return $this->publicSeo()->maybeAttachCategory($payload, $params); } /** @@ -223,8 +228,13 @@ public function getList(array $params): array $this->applySort($listQuery, $params); $listQuery->limit($limit, $offset); + $items = $this->publicSeo()->attachSeoToCategoryList( + $this->fetchFormattedCategories($listQuery, $includeContent), + $params, + ); + return [ - 'items' => $this->fetchFormattedCategories($listQuery, $includeContent), + 'items' => $items, 'total' => $total, 'limit' => $limit, 'offset' => $offset, @@ -249,7 +259,10 @@ public function getTree(array $params): array [$byId, $childrenByParent] = $this->indexCategoryRows($rows); return [ - 'items' => self::buildTreeNodes($byId, $childrenByParent, $parent, $depth), + 'items' => $this->publicSeo()->attachSeoToCategoryTree( + self::buildTreeNodes($byId, $childrenByParent, $parent, $depth), + $params, + ), ]; } @@ -591,9 +604,17 @@ private function applyVisibilityFilters(xPDOQuery $query, array $params, bool $i } } + private function publicSeo(): PublicSeoService + { + /** @var PublicSeoService $service */ + $service = $this->modx->services->get('ms3_public_seo'); + + return $service; + } + private function castResourceField(string $field, mixed $value): mixed { - if ($field === 'hidemenu') { + if ($field === 'hidemenu' || $field === 'searchable') { return (bool) $value; } if (in_array($field, self::INT_FIELDS, true)) { diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogService.php b/core/components/minishop3/src/Services/Product/ProductCatalogService.php index 784eda81a..755f03798 100644 --- a/core/components/minishop3/src/Services/Product/ProductCatalogService.php +++ b/core/components/minishop3/src/Services/Product/ProductCatalogService.php @@ -40,6 +40,7 @@ class ProductCatalogService 'publishedon', 'createdon', 'editedon', + 'searchable', ]; /** @var list */ @@ -168,7 +169,7 @@ public static function toBool(mixed $value): bool * Single published product by ID (same visibility rules as list). * * Query: context, include_images (0|1, default 0), include_seo (default 1). - * List payloads omit seo. + * List: include_seo (default 0). * * @param array $params Optional context override * @return array|null @@ -189,10 +190,7 @@ public function getById(int $productId, array $params = []): ?array $payload = $this->formatProduct($product, true, $options, $images); - /** @var PublicSeoService $seo */ - $seo = $this->modx->services->get('ms3_public_seo'); - - return $seo->maybeAttachProduct($payload, $params); + return $this->publicSeo()->maybeAttachProduct($payload, $params); } /** @@ -308,6 +306,7 @@ public function resolveByLookup( * - options: JSON object or bracket map (AND between keys, OR within key) * - limit, offset | page, sort, dir, query, context * - include_options, include_content, include_images (default 0; cap 10 files / product) + * - include_seo (default 0) * * @param array $params * @return array{items: list>, total: int, limit: int, offset: int} @@ -352,6 +351,8 @@ public function getList(array $params): array $items[] = $this->formatProduct($product, $includeContent, $options, $images); } + $items = $this->publicSeo()->attachSeoToProductList($items, $params); + return [ 'items' => $items, 'total' => $total, @@ -675,7 +676,11 @@ private function formatProduct( $payload = []; foreach (self::RESOURCE_FIELDS as $field) { - $payload[$field] = $product->get($field); + $value = $product->get($field); + if ($field === 'searchable') { + $value = CatalogQuery::toBool($value); + } + $payload[$field] = $value; } $dataValues = []; @@ -724,6 +729,14 @@ private function formatProduct( ); } + private function publicSeo(): PublicSeoService + { + /** @var PublicSeoService $service */ + $service = $this->modx->services->get('ms3_public_seo'); + + return $service; + } + private function gallery(): ProductGalleryPublicService { /** @var ProductGalleryPublicService $service */ diff --git a/core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php b/core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php index 2c749d544..d0d9fd4f8 100644 --- a/core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php +++ b/core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php @@ -4,14 +4,19 @@ namespace MiniShop3\Services\Seo; +use MiniShop3\Services\Catalog\CatalogQuery; + /** - * Core SEO projection for public catalog JSON (#567). + * Core SEO projection for public catalog JSON (#567, #703). * * Fallbacks: title ← longtitle|pagetitle; description ← description|introtext; - * canonical / og.image ← absolute site_url + uri/image|thumb; robots = index,follow. + * canonical ← absolute site_url + uri (PublicSeoService may override via makeUrl); + * og.image ← absolute site_url + image|thumb; robots from searchable when present. * og.type is product|website. Unknown keys are dropped by whitelist(). - * No third-party SEO Extra. Optional overrides: event msOnGetPublicSeo - * (returnedValues['seo'] assoc patch). TV map ms3_public_seo_tv_map is a follow-up. + * TV overlay and canonical makeUrl live in PublicSeoService. + * + * SOFT (#703): og.image uses a single URL (image|thumb). Multi-size gallery og + * variants deferred — see issue #703 item 5 / #566. */ final class PublicSeoBuilder { @@ -67,7 +72,7 @@ public static function build(array $fields, string $siteUrl, string $ogType): ar 'title' => $title, 'description' => $description, 'canonical' => self::absoluteUrl($siteUrl, (string) ($fields['uri'] ?? '')), - 'robots' => 'index,follow', + 'robots' => self::resolveRobots($fields), 'og' => [ 'title' => $title, 'description' => $description, @@ -102,6 +107,18 @@ public static function whitelist(array $seo): array return $out; } + /** + * @param array $fields + */ + private static function resolveRobots(array $fields): string + { + if (!array_key_exists('searchable', $fields)) { + return 'index,follow'; + } + + return CatalogQuery::toBool($fields['searchable']) ? 'index,follow' : 'noindex,nofollow'; + } + /** * First non-empty trimmed string among payload keys. * diff --git a/core/components/minishop3/src/Services/Seo/PublicSeoService.php b/core/components/minishop3/src/Services/Seo/PublicSeoService.php index 8c24ad76e..448ba8967 100644 --- a/core/components/minishop3/src/Services/Seo/PublicSeoService.php +++ b/core/components/minishop3/src/Services/Seo/PublicSeoService.php @@ -9,13 +9,27 @@ use MODX\Revolution\modX; /** - * Attach allowlisted `seo` to a public catalog payload (#567). + * Attach allowlisted `seo` to public catalog payloads (#567, #703). * - * Optional enrichment: plugins on msOnGetPublicSeo may set - * $modx->event->returnedValues['seo'] (assoc patch). Core has no SEO Extra. + * Flow: PublicSeoBuilder → canonical (makeUrl) → ms3_public_seo_tv_map overlay → + * whitelist → msOnGetPublicSeo → mirror og text → whitelist. + * + * Query flag `include_seo`: default 1 on product/category get; default 0 on list/tree. + * + * Event msOnGetPublicSeo (product and category): + * - Invoked after core build and TV overlay, before the final whitelist. + * - Params: seo (current array), payload (catalog row), og_type (product|website). + * - Plugins may set $modx->event->returnedValues['seo'] as an assoc patch; unknown keys + * are stripped. og.title / og.description mirror title / description unless explicitly + * patched in returnedValues['seo']['og']. */ final class PublicSeoService { + /** @var array|null */ + private ?array $parsedTvMap = null; + + private ?PublicSeoTvMap $tvMap = null; + public function __construct( private modX $modx, ) { @@ -41,6 +55,60 @@ public function maybeAttachCategory(array $payload, array $params): array return $this->maybeAttach($payload, $params, PublicSeoBuilder::OG_TYPE_CATEGORY); } + /** + * @param list> $items + * @param array $params + * @return list> + */ + public function attachSeoToProductList(array $items, array $params): array + { + return $this->attachSeoToList($items, $params, PublicSeoBuilder::OG_TYPE_PRODUCT); + } + + /** + * @param list> $items + * @param array $params + * @return list> + */ + public function attachSeoToCategoryList(array $items, array $params): array + { + return $this->attachSeoToList($items, $params, PublicSeoBuilder::OG_TYPE_CATEGORY); + } + + /** + * Attach seo to each node in a category tree (recursive). + * + * @param list> $nodes + * @param array $params + * @return list> + */ + public function attachSeoToCategoryTree(array $nodes, array $params): array + { + if (!CatalogQuery::resolveBool($params, 'include_seo', false)) { + return $nodes; + } + + return $this->attachSeoToCategoryTreeNodes($nodes, $params); + } + + /** + * @param list> $nodes + * @param array $params + * @return list> + */ + private function attachSeoToCategoryTreeNodes(array $nodes, array $params): array + { + $result = []; + foreach ($nodes as $node) { + if (isset($node['children']) && is_array($node['children'])) { + $node['children'] = $this->attachSeoToCategoryTreeNodes($node['children'], $params); + } + $result[] = $this->attach($node, $params, PublicSeoBuilder::OG_TYPE_CATEGORY); + } + + return $result; + } + /** * @param array $payload * @param array $params @@ -52,11 +120,40 @@ private function maybeAttach(array $payload, array $params, string $ogType): arr return $payload; } + return $this->attach($payload, $params, $ogType); + } + + /** + * @param array $payload + * @param array $params + * @return array + */ + private function attach(array $payload, array $params, string $ogType): array + { $payload['seo'] = $this->build($payload, $params, $ogType); return $payload; } + /** + * @param list> $items + * @param array $params + * @return list> + */ + private function attachSeoToList(array $items, array $params, string $ogType): array + { + if (!CatalogQuery::resolveBool($params, 'include_seo', false)) { + return $items; + } + + $result = []; + foreach ($items as $item) { + $result[] = $this->attach($item, $params, $ogType); + } + + return $result; + } + /** * @param array $payload * @param array $params @@ -64,7 +161,21 @@ private function maybeAttach(array $payload, array $params, string $ogType): arr */ private function build(array $payload, array $params, string $ogType): array { - $seo = PublicSeoBuilder::build($payload, $this->siteUrl($payload, $params), $ogType); + $siteUrl = $this->siteUrl($payload, $params); + $seo = PublicSeoBuilder::build($payload, $siteUrl, $ogType); + $seo['canonical'] = $this->resolveCanonical($payload, $params, $siteUrl); + + $resourceId = (int) ($payload['id'] ?? 0); + $tvMap = $this->parsedTvMap(); + $tvExplicitPatch = null; + if ($resourceId > 0 && $tvMap !== []) { + [$seo, $appliedKeys] = $this->tvMap()->overlay($seo, $resourceId, $tvMap, $siteUrl); + if ($appliedKeys !== []) { + $tvExplicitPatch = self::explicitPatchFromAppliedKeys($appliedKeys, $seo); + } + } + + $seo = PublicSeoBuilder::whitelist($seo); $event = EventGate::invokeRaw($this->modx, 'msOnGetPublicSeo', [ 'seo' => $seo, @@ -74,7 +185,31 @@ private function build(array $payload, array $params, string $ogType): array $patch = $event['returnedValues']['seo'] ?? null; $seo = EventGate::applyReturnedArray($seo, $event['returnedValues'], 'seo'); - return PublicSeoBuilder::whitelist(self::mirrorOgText($seo, $patch)); + return PublicSeoBuilder::whitelist(self::mirrorOgText($seo, self::mergeSeoPatches($tvExplicitPatch, $patch))); + } + + /** + * Prefer MODX makeUrl (friendly_urls=0 safe); fallback to site_url + uri. + * + * @param array $payload + * @param array $params + */ + private function resolveCanonical(array $payload, array $params, string $siteUrl): string + { + $id = (int) ($payload['id'] ?? 0); + if ($id > 0) { + $url = trim((string) $this->modx->makeUrl( + $id, + $this->resolveContextKey($payload, $params), + '', + 'full', + )); + if ($url !== '') { + return $url; + } + } + + return PublicSeoBuilder::absoluteUrl($siteUrl, (string) ($payload['uri'] ?? '')); } /** @@ -83,9 +218,7 @@ private function build(array $payload, array $params, string $ogType): array */ private function siteUrl(array $payload, array $params): string { - $fromPayload = trim((string) ($payload['context_key'] ?? '')); - $contextKey = CatalogQuery::resolveContext($params, $fromPayload !== '' ? $fromPayload : 'web'); - $context = $this->modx->getContext($contextKey); + $context = $this->modx->getContext($this->resolveContextKey($payload, $params)); if (is_object($context) && method_exists($context, 'getOption')) { $url = trim((string) $context->getOption('site_url')); if ($url !== '') { @@ -96,6 +229,97 @@ private function siteUrl(array $payload, array $params): string return (string) $this->modx->getOption('site_url', null, ''); } + /** + * @param array $payload + * @param array $params + */ + private function resolveContextKey(array $payload, array $params): string + { + $fromPayload = trim((string) ($payload['context_key'] ?? '')); + + return CatalogQuery::resolveContext($params, $fromPayload !== '' ? $fromPayload : 'web'); + } + + /** + * @return array + */ + private function parsedTvMap(): array + { + if ($this->parsedTvMap === null) { + $this->parsedTvMap = PublicSeoTvMap::parseSettingValue( + $this->modx->getOption(PublicSeoTvMap::SETTING_KEY, null, ''), + ); + } + + return $this->parsedTvMap; + } + + private function tvMap(): PublicSeoTvMap + { + if ($this->tvMap === null) { + $this->tvMap = new PublicSeoTvMap($this->modx); + } + + return $this->tvMap; + } + + /** + * Build a synthetic plugin patch so mirrorOgText treats TV-written og keys as explicit. + * + * @param list $appliedKeys + * @param array $seo + * @return array + */ + private static function explicitPatchFromAppliedKeys(array $appliedKeys, array $seo): array + { + $patch = []; + foreach ($appliedKeys as $key) { + if (!str_starts_with($key, 'og.')) { + if (array_key_exists($key, $seo)) { + $patch[$key] = $seo[$key]; + } + continue; + } + + $ogKey = substr($key, 3); + $og = isset($seo['og']) && is_array($seo['og']) ? $seo['og'] : []; + if (array_key_exists($ogKey, $og)) { + $patch['og'] ??= []; + $patch['og'][$ogKey] = $og[$ogKey]; + } + } + + return $patch; + } + + /** + * @param array|null $base + * @return array|null + */ + private static function mergeSeoPatches(?array $base, mixed $overlay): ?array + { + if (!is_array($overlay) || $overlay === []) { + return $base; + } + if ($base === null || $base === []) { + return $overlay; + } + + $merged = $base; + foreach ($overlay as $key => $value) { + if ($key === 'og' && is_array($value)) { + $merged['og'] = array_merge( + is_array($merged['og'] ?? null) ? $merged['og'] : [], + $value, + ); + continue; + } + $merged[$key] = $value; + } + + return $merged; + } + /** * Keep og.title / og.description in sync with title / description unless the plugin * explicitly patched those og keys. diff --git a/core/components/minishop3/src/Services/Seo/PublicSeoTvMap.php b/core/components/minishop3/src/Services/Seo/PublicSeoTvMap.php new file mode 100644 index 000000000..18670abed --- /dev/null +++ b/core/components/minishop3/src/Services/Seo/PublicSeoTvMap.php @@ -0,0 +1,174 @@ + */ + private array $tvCache = []; + + public function __construct( + private modX $modx, + ) { + } + + /** + * Parse setting value into a validated seoKey => tvRef map. + * + * @return array + */ + public static function parseSettingValue(mixed $raw): array + { + if (!is_string($raw) || trim($raw) === '') { + return []; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + + $map = []; + foreach ($decoded as $seoKey => $tvRef) { + if (!is_string($seoKey) || !is_string($tvRef)) { + continue; + } + + $seoKey = trim($seoKey); + $tvRef = trim($tvRef); + if ($seoKey === '' || $tvRef === '') { + continue; + } + + if (!self::isAllowedSeoKey($seoKey)) { + continue; + } + + $tvName = self::parseTvReference($tvRef); + if ($tvName === null) { + continue; + } + + $map[$seoKey] = $tvName; + } + + return $map; + } + + /** + * Load mapped TV values for a resource and merge into the seo array. + * + * Relative canonical and og.image values are resolved with {@see PublicSeoBuilder::absoluteUrl}. + * SOFT (#703): batch prefetch of TV values across resource IDs is deferred. + * + * @param array $seo + * @param array $map seoKey => TV name (without tv. prefix) + * @return array{0: array, 1: list} seo and applied allowlisted keys + */ + public function overlay(array $seo, int $resourceId, array $map, string $siteUrl = ''): array + { + /** @var list $applied */ + $applied = []; + + if ($resourceId <= 0 || $map === []) { + return [$seo, $applied]; + } + + foreach ($map as $seoKey => $tvName) { + $value = $this->loadTvValue($resourceId, $tvName); + if ($value === '') { + continue; + } + + if ($seoKey === 'canonical' || $seoKey === 'og.image') { + $value = PublicSeoBuilder::absoluteUrl($siteUrl, $value); + } + + $ogKey = self::ogSubKey($seoKey); + if ($ogKey !== null) { + $og = isset($seo['og']) && is_array($seo['og']) ? $seo['og'] : []; + $og[$ogKey] = $value; + $seo['og'] = $og; + $applied[] = $seoKey; + continue; + } + + $seo[$seoKey] = $value; + $applied[] = $seoKey; + } + + return [$seo, $applied]; + } + + private static function isAllowedSeoKey(string $key): bool + { + if ($key !== 'og' && in_array($key, PublicSeoBuilder::KEYS, true)) { + return true; + } + + return self::ogSubKey($key) !== null; + } + + private static function ogSubKey(string $seoKey): ?string + { + if (!str_starts_with($seoKey, 'og.')) { + return null; + } + + $ogKey = substr($seoKey, 3); + + return in_array($ogKey, PublicSeoBuilder::OG_KEYS, true) ? $ogKey : null; + } + + /** + * Accepts "tv.name" or "name"; returns normalized TV name or null when unsafe. + */ + private static function parseTvReference(string $ref): ?string + { + $name = str_starts_with($ref, 'tv.') ? substr($ref, 3) : $ref; + $name = trim($name); + if ($name === '' || preg_match(self::TV_NAME_PATTERN, $name) !== 1) { + return null; + } + + return $name; + } + + private function loadTvValue(int $resourceId, string $tvName): string + { + $tv = $this->resolveTv($tvName); + if ($tv === null) { + return ''; + } + + return trim((string) $tv->renderOutput($resourceId)); + } + + private function resolveTv(string $tvName): ?modTemplateVar + { + if (!array_key_exists($tvName, $this->tvCache)) { + /** @var modTemplateVar|null $tv */ + $tv = $this->modx->getObject(modTemplateVar::class, ['name' => $tvName]); + $this->tvCache[$tvName] = $tv; + } + + return $this->tvCache[$tvName]; + } +} diff --git a/core/components/minishop3/tests/PublicSeoRoutesTest.php b/core/components/minishop3/tests/PublicSeoRoutesTest.php index 7d9791ebc..4df5046ef 100644 --- a/core/components/minishop3/tests/PublicSeoRoutesTest.php +++ b/core/components/minishop3/tests/PublicSeoRoutesTest.php @@ -1,7 +1,7 @@ getById\(/s', $productCatalog)) { @@ -70,14 +85,15 @@ $fail('category catalog must not import PublicSeoBuilder'); } -if (preg_match('/function getList.*?maybeAttach/s', $productCatalog)) { - $fail('product getList must not attach seo'); +if (!str_contains($categoryCatalog, 'attachSeoToCategoryTree')) { + $fail('category getTree must support include_seo via attachSeoToCategoryTree'); } -if (preg_match('/function getList.*?maybeAttach/s', $categoryCatalog)) { - $fail('category getList must not attach seo'); + +if (!str_contains($productCatalog, "'searchable'")) { + $fail('product RESOURCE_FIELDS must include searchable'); } -if (preg_match('/function getTree.*?maybeAttach/s', $categoryCatalog)) { - $fail('category getTree must not attach seo'); +if (!str_contains($categoryCatalog, "'searchable'")) { + $fail('category RESOURCE_FIELDS must include searchable'); } if (!str_contains($productController, 'include_seo')) { diff --git a/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoBuilderTest.php b/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoBuilderTest.php index aa9cba8e8..e243bd1ec 100644 --- a/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoBuilderTest.php +++ b/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoBuilderTest.php @@ -124,6 +124,18 @@ public function testOgTypeAndDefaultRobots(): void self::assertSame('website', $category['og']['type']); } + public function testRobotsFromSearchableWhenPresent(): void + { + $indexed = PublicSeoBuilder::build(['searchable' => 1], 'https://shop.example/', PublicSeoBuilder::OG_TYPE_PRODUCT); + self::assertSame('index,follow', $indexed['robots']); + + $hidden = PublicSeoBuilder::build(['searchable' => 0], 'https://shop.example/', PublicSeoBuilder::OG_TYPE_PRODUCT); + self::assertSame('noindex,nofollow', $hidden['robots']); + + $default = PublicSeoBuilder::build([], 'https://shop.example/', PublicSeoBuilder::OG_TYPE_PRODUCT); + self::assertSame('index,follow', $default['robots']); + } + public function testWhitelistDropsUnknownKeys(): void { $clean = PublicSeoBuilder::whitelist([ diff --git a/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoServiceTest.php b/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoServiceTest.php index 0bf190526..edaba8075 100644 --- a/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoServiceTest.php +++ b/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoServiceTest.php @@ -5,6 +5,8 @@ namespace MiniShop3\Tests\Unit\Services\Seo; use MiniShop3\Services\Seo\PublicSeoService; +use MiniShop3\Services\Seo\PublicSeoTvMap; +use MODX\Revolution\modTemplateVar; use MODX\Revolution\modX; use PHPUnit\Framework\TestCase; @@ -15,6 +17,9 @@ protected function setUp(): void if (!class_exists(modX::class, false)) { require_once dirname(__DIR__, 3) . '/stubs/ModxStub.php'; } + if (!class_exists(modTemplateVar::class, false)) { + require_once dirname(__DIR__, 3) . '/stubs/ModTemplateVarStub.php'; + } } public function testDefaultOnAttachesSeo(): void @@ -89,6 +94,7 @@ public function testQueryContextOverridesPayloadContext(): void 'en' => 'https://en.example/', ])->maybeAttachProduct( [ + 'id' => 0, 'pagetitle' => 'Kettle', 'uri' => 'catalog/kettle/', 'context_key' => 'web', @@ -99,31 +105,193 @@ public function testQueryContextOverridesPayloadContext(): void self::assertSame('https://en.example/catalog/kettle/', $out['seo']['canonical']); } + public function testCanonicalUsesMakeUrlWhenResourceIdPresent(): void + { + $out = $this->service(null, [], ['42@web' => 'https://shop.example/index.php?id=42'])->maybeAttachProduct( + [ + 'id' => 42, + 'pagetitle' => 'Kettle', + 'uri' => 'catalog/kettle/', + 'context_key' => 'web', + ], + [], + ); + + self::assertSame('https://shop.example/index.php?id=42', $out['seo']['canonical']); + } + + public function testCanonicalFallsBackToUriWhenMakeUrlEmpty(): void + { + $out = $this->service(null, [], [])->maybeAttachProduct( + [ + 'id' => 42, + 'pagetitle' => 'Kettle', + 'uri' => 'catalog/kettle/', + 'context_key' => 'web', + ], + [], + ); + + self::assertSame('https://shop.example/catalog/kettle/', $out['seo']['canonical']); + } + + public function testTvMapFromGetOptionAppliesOverlay(): void + { + $out = $this->service(null, [], [], [ + 'seo_title' => 'TV meta title', + ], '{"title":"tv.seo_title"}')->maybeAttachProduct( + [ + 'id' => 42, + 'pagetitle' => 'Kettle', + 'longtitle' => '', + ], + [], + ); + + self::assertSame('TV meta title', $out['seo']['title']); + } + + public function testTvOgTitleSurvivesMirrorOgTextWhenDifferentFromTitle(): void + { + $out = $this->service(null, [], [], [ + 'seo_title' => 'Page title', + 'og_title' => 'Social headline', + ], '{"title":"tv.seo_title","og.title":"tv.og_title"}')->maybeAttachProduct( + [ + 'id' => 42, + 'pagetitle' => 'Kettle', + 'longtitle' => '', + ], + [], + ); + + self::assertSame('Page title', $out['seo']['title']); + self::assertSame('Social headline', $out['seo']['og']['title']); + } + + public function testRelativeTvOgImageBecomesAbsolute(): void + { + $out = $this->service(null, [], [], [ + 'og_image' => '/assets/social.jpg', + ], '{"og.image":"tv.og_image"}')->maybeAttachProduct( + [ + 'id' => 42, + 'pagetitle' => 'Kettle', + 'uri' => 'catalog/kettle/', + ], + [], + ); + + self::assertSame('https://shop.example/assets/social.jpg', $out['seo']['og']['image']); + } + + public function testAttachSeoToProductListDefaultsToOmittingSeo(): void + { + $items = [['pagetitle' => 'Kettle'], ['pagetitle' => 'Mug']]; + $out = $this->service()->attachSeoToProductList($items, []); + + self::assertArrayNotHasKey('seo', $out[0]); + self::assertArrayNotHasKey('seo', $out[1]); + } + + public function testAttachSeoToProductListWithIncludeSeo(): void + { + $items = [['pagetitle' => 'Kettle']]; + $out = $this->service()->attachSeoToProductList($items, ['include_seo' => 1]); + + self::assertArrayHasKey('seo', $out[0]); + self::assertSame('Kettle', $out[0]['seo']['title']); + } + + public function testAttachSeoToCategoryListDefaultsToOmittingSeo(): void + { + $items = [['pagetitle' => 'Tea']]; + $out = $this->service()->attachSeoToCategoryList($items, []); + + self::assertArrayNotHasKey('seo', $out[0]); + } + + public function testAttachSeoToCategoryListWithIncludeSeo(): void + { + $items = [['pagetitle' => 'Tea']]; + $out = $this->service()->attachSeoToCategoryList($items, ['include_seo' => 1]); + + self::assertSame('website', $out[0]['seo']['og']['type']); + } + + public function testAttachSeoToCategoryTreeDefaultsToOmittingSeo(): void + { + $nodes = [ + [ + 'pagetitle' => 'Root', + 'children' => [['pagetitle' => 'Child']], + ], + ]; + $out = $this->service()->attachSeoToCategoryTree($nodes, []); + + self::assertArrayNotHasKey('seo', $out[0]); + self::assertArrayNotHasKey('seo', $out[0]['children'][0]); + } + + public function testAttachSeoToCategoryTreeRecursivelyWithIncludeSeo(): void + { + $nodes = [ + [ + 'pagetitle' => 'Root', + 'children' => [['pagetitle' => 'Child']], + ], + ]; + $out = $this->service()->attachSeoToCategoryTree($nodes, ['include_seo' => 1]); + + self::assertSame('Root', $out[0]['seo']['title']); + self::assertSame('Child', $out[0]['children'][0]['seo']['title']); + } + /** * @param array|null $seoPatch * @param array $contextUrls + * @param array $makeUrls "id@context" => url + * @param array $tvValues tvName => rendered value + * @param string|null $tvMapSetting raw ms3_public_seo_tv_map JSON */ - private function service(?array $seoPatch = null, array $contextUrls = []): PublicSeoService - { - return new PublicSeoService($this->modx($seoPatch, $contextUrls)); + private function service( + ?array $seoPatch = null, + array $contextUrls = [], + array $makeUrls = [], + array $tvValues = [], + ?string $tvMapSetting = null, + ): PublicSeoService { + return new PublicSeoService($this->modx($seoPatch, $contextUrls, $makeUrls, $tvValues, $tvMapSetting)); } /** * @param array|null $seoPatch * @param array $contextUrls + * @param array $makeUrls + * @param array $tvValues */ - private function modx(?array $seoPatch = null, array $contextUrls = []): modX - { - return new class ($seoPatch, $contextUrls) extends modX { + private function modx( + ?array $seoPatch = null, + array $contextUrls = [], + array $makeUrls = [], + array $tvValues = [], + ?string $tvMapSetting = null, + ): modX { + return new class ($seoPatch, $contextUrls, $makeUrls, $tvValues, $tvMapSetting) extends modX { public object $event; /** * @param array|null $seoPatch * @param array $contextUrls + * @param array $makeUrls + * @param array $tvValues */ public function __construct( private ?array $seoPatch, private array $contextUrls, + private array $makeUrls, + private array $tvValues, + private ?string $tvMapSetting, ) { parent::__construct(); $this->event = (object) ['returnedValues' => null]; @@ -131,6 +299,10 @@ public function __construct( public function getOption(string $key, $options = null, $default = null) { + if ($key === PublicSeoTvMap::SETTING_KEY) { + return $this->tvMapSetting ?? $default; + } + return $key === 'site_url' ? 'https://shop.example/' : $default; } @@ -153,6 +325,13 @@ public function getOption(string $key, $options = null, $default = null) }; } + public function makeUrl($id, $context = '', $args = '', $scheme = 'full', array $options = []) + { + $key = (string) $id . '@' . ($context !== '' ? $context : 'web'); + + return $this->makeUrls[$key] ?? ''; + } + public function invokeEvent($eventName, array $params = []) { if ($this->seoPatch !== null) { @@ -161,6 +340,31 @@ public function invokeEvent($eventName, array $params = []) return []; } + + public function getObject($className, $criteria = null, $cacheFlag = true) + { + if ($className !== modTemplateVar::class || !is_array($criteria)) { + return null; + } + + $name = $criteria['name'] ?? null; + if (!is_string($name) || !array_key_exists($name, $this->tvValues)) { + return null; + } + + return new class ($name, $this->tvValues[$name]) extends modTemplateVar { + public function __construct( + private string $name, + private string $value, + ) { + } + + public function renderOutput($resourceId = 0) + { + return $this->value; + } + }; + } }; } } diff --git a/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoTvMapTest.php b/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoTvMapTest.php new file mode 100644 index 000000000..a087cb805 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoTvMapTest.php @@ -0,0 +1,173 @@ + 'seo_title', + 'robots' => 'robots', + 'og.image' => 'og_image', + ], $map); + } + + public function testParseSettingValueRejectsUnknownKeysAndBadJson(): void + { + self::assertSame([], PublicSeoTvMap::parseSettingValue('{not json')); + self::assertSame([], PublicSeoTvMap::parseSettingValue('')); + self::assertSame([], PublicSeoTvMap::parseSettingValue('{"jsonld":"tv.leak"}')); + + $map = PublicSeoTvMap::parseSettingValue('{"title":"tv.","robots":"tv.bad name"}'); + self::assertSame([], $map); + } + + public function testOverlayMergesLoadedTvValues(): void + { + $seo = [ + 'title' => 'Core', + 'description' => '', + 'canonical' => '', + 'robots' => 'index,follow', + 'og' => ['title' => 'Core', 'description' => '', 'image' => '', 'type' => 'product'], + ]; + + [$overlay] = (new PublicSeoTvMap($this->modx(['seo_title' => 'TV title'])))->overlay( + $seo, + 42, + ['title' => 'seo_title'], + ); + + self::assertSame('TV title', $overlay['title']); + self::assertSame('index,follow', $overlay['robots']); + } + + public function testOverlaySkipsEmptyTvValues(): void + { + $seo = [ + 'title' => 'Core', + 'description' => '', + 'canonical' => '', + 'robots' => 'index,follow', + 'og' => ['title' => 'Core', 'description' => '', 'image' => '', 'type' => 'product'], + ]; + + [$overlay] = (new PublicSeoTvMap($this->modx([])))->overlay( + $seo, + 42, + ['title' => 'missing_tv'], + ); + + self::assertSame('Core', $overlay['title']); + } + + public function testOverlayReturnsAppliedKeys(): void + { + $seo = [ + 'title' => 'Core', + 'description' => '', + 'canonical' => '', + 'robots' => 'index,follow', + 'og' => ['title' => 'Core', 'description' => '', 'image' => '', 'type' => 'product'], + ]; + + [$overlay, $applied] = (new PublicSeoTvMap($this->modx([ + 'seo_title' => 'TV title', + 'og_title' => 'TV og title', + ])))->overlay( + $seo, + 42, + ['title' => 'seo_title', 'og.title' => 'og_title'], + ); + + self::assertSame(['title', 'og.title'], $applied); + self::assertSame('TV og title', $overlay['og']['title']); + } + + public function testOverlayAbsoluteUrlForCanonicalAndOgImage(): void + { + $seo = [ + 'title' => 'Core', + 'description' => '', + 'canonical' => '', + 'robots' => 'index,follow', + 'og' => ['title' => 'Core', 'description' => '', 'image' => '', 'type' => 'product'], + ]; + + [$overlay] = (new PublicSeoTvMap($this->modx([ + 'seo_canonical' => '/custom/path/', + 'og_image' => '/assets/og.jpg', + ])))->overlay( + $seo, + 42, + ['canonical' => 'seo_canonical', 'og.image' => 'og_image'], + 'https://shop.example/', + ); + + self::assertSame('https://shop.example/custom/path/', $overlay['canonical']); + self::assertSame('https://shop.example/assets/og.jpg', $overlay['og']['image']); + } + + /** + * @param array $tvValues tvName => rendered value + */ + private function modx(array $tvValues): modX + { + return new class ($tvValues) extends modX { + /** + * @param array $tvValues + */ + public function __construct(private array $tvValues) + { + parent::__construct(); + } + + public function getObject($className, $criteria = null, $cacheFlag = true) + { + if ($className !== modTemplateVar::class || !is_array($criteria)) { + return null; + } + + $name = $criteria['name'] ?? null; + if (!is_string($name) || !array_key_exists($name, $this->tvValues)) { + return null; + } + + return new class ($name, $this->tvValues[$name]) extends modTemplateVar { + public function __construct( + private string $name, + private string $value, + ) { + } + + public function renderOutput($resourceId = 0) + { + return $this->value; + } + }; + } + }; + } +} diff --git a/core/components/minishop3/tests/stubs/ModTemplateVarStub.php b/core/components/minishop3/tests/stubs/ModTemplateVarStub.php new file mode 100644 index 000000000..ad04b0c2f --- /dev/null +++ b/core/components/minishop3/tests/stubs/ModTemplateVarStub.php @@ -0,0 +1,16 @@ +