From c870b01b08bf228456e9e17b85313d36cbd74b4a Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Tue, 18 Aug 2026 08:24:58 +0600 Subject: [PATCH] feat(web-api): add public seo block for product and category get Headless SSR needs a stable title/canonical/og contract instead of guessing longtitle vs pagetitle and joining site_url by hand. --- _build/elements/events.php | 1 + .../Api/Web/CategoryController.php | 3 + .../Controllers/Api/Web/ProductController.php | 2 + .../minishop3/src/ServiceRegistry.php | 4 + .../src/ServiceRegistryFactories.php | 1 + .../Category/CategoryCatalogService.php | 9 +- .../Product/ProductCatalogService.php | 10 +- .../src/Services/Seo/PublicSeoBuilder.php | 142 +++++++++++++++ .../src/Services/Seo/PublicSeoService.php | 120 ++++++++++++ .../minishop3/tests/PublicSeoRoutesTest.php | 90 +++++++++ .../Services/Seo/PublicSeoBuilderTest.php | 171 ++++++++++++++++++ .../Services/Seo/PublicSeoServiceTest.php | 166 +++++++++++++++++ 12 files changed, 717 insertions(+), 2 deletions(-) create mode 100644 core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php create mode 100644 core/components/minishop3/src/Services/Seo/PublicSeoService.php create mode 100644 core/components/minishop3/tests/PublicSeoRoutesTest.php create mode 100644 core/components/minishop3/tests/Unit/Services/Seo/PublicSeoBuilderTest.php create mode 100644 core/components/minishop3/tests/Unit/Services/Seo/PublicSeoServiceTest.php diff --git a/_build/elements/events.php b/_build/elements/events.php index fd235f233..f28569dad 100644 --- a/_build/elements/events.php +++ b/_build/elements/events.php @@ -83,6 +83,7 @@ 'msOnGetProductPrice', 'msOnGetProductWeight', 'msOnGetProductFields', + 'msOnGetPublicSeo', // msProducts snippet events (for extending with external packages) 'msOnProductsLoad', // After loading products, for bulk data loading diff --git a/core/components/minishop3/src/Controllers/Api/Web/CategoryController.php b/core/components/minishop3/src/Controllers/Api/Web/CategoryController.php index 01edf1cd8..12a931824 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/CategoryController.php +++ b/core/components/minishop3/src/Controllers/Api/Web/CategoryController.php @@ -27,6 +27,9 @@ public function __construct(modX $modx) /** * GET /api/v1/category/get/{id} * + * Query: context, include_hidden, include_content, include_breadcrumbs, + * include_children, include_seo (default 1). + * * @param array $params */ public function get(array $params = []): Response diff --git a/core/components/minishop3/src/Controllers/Api/Web/ProductController.php b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php index 35a2164c0..e8109f655 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/ProductController.php +++ b/core/components/minishop3/src/Controllers/Api/Web/ProductController.php @@ -29,6 +29,8 @@ public function __construct(modX $modx) /** * GET /api/v1/product/get/{id} * + * Query: context, include_seo (default 1). + * * @param array $params */ public function get(array $params = []): Response diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 2e1e25643..b8217dd0e 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -162,6 +162,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Product\ProductFacetService::class, 'interface' => null, ], + 'ms3_public_seo' => [ + 'class' => \MiniShop3\Services\Seo\PublicSeoService::class, + 'interface' => null, + ], 'ms3_category_catalog' => [ 'class' => \MiniShop3\Services\Category\CategoryCatalogService::class, 'interface' => null, diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php index 522b9ac88..6789e92f8 100644 --- a/core/components/minishop3/src/ServiceRegistryFactories.php +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -40,6 +40,7 @@ public static function map(): array 'ms3_product_link_service' => $modxOnly(), 'ms3_product_catalog' => $modxOnly(), 'ms3_product_facets' => $modxOnly(), + 'ms3_public_seo' => $modxOnly(), 'ms3_category_catalog' => $modxOnly(), 'ms3_delivery_catalog' => $modxOnly(), 'ms3_payment_catalog' => $modxOnly(), diff --git a/core/components/minishop3/src/Services/Category/CategoryCatalogService.php b/core/components/minishop3/src/Services/Category/CategoryCatalogService.php index e5292cf1e..4f9b6ccfa 100644 --- a/core/components/minishop3/src/Services/Category/CategoryCatalogService.php +++ b/core/components/minishop3/src/Services/Category/CategoryCatalogService.php @@ -6,6 +6,7 @@ use MiniShop3\Model\msCategory; use MiniShop3\Services\Catalog\CatalogQuery; +use MiniShop3\Services\Seo\PublicSeoService; use MODX\Revolution\modX; use xPDO\Om\xPDOQuery; @@ -125,6 +126,9 @@ public static function buildTreeNodes( } /** + * Query: context, include_hidden, include_content, include_breadcrumbs, + * include_children, include_seo (default 1). List/tree omit seo. + * * @param array $params * @return array|null */ @@ -158,7 +162,10 @@ public function getById(int $categoryId, array $params = []): ?array ); } - return $payload; + /** @var PublicSeoService $seo */ + $seo = $this->modx->services->get('ms3_public_seo'); + + return $seo->maybeAttachCategory($payload, $params); } /** diff --git a/core/components/minishop3/src/Services/Product/ProductCatalogService.php b/core/components/minishop3/src/Services/Product/ProductCatalogService.php index 2eb6e11b8..7f4a3ee7f 100644 --- a/core/components/minishop3/src/Services/Product/ProductCatalogService.php +++ b/core/components/minishop3/src/Services/Product/ProductCatalogService.php @@ -9,6 +9,7 @@ use MiniShop3\Services\Catalog\CatalogQuery; use MiniShop3\Services\Category\CategoryProductScopeService; use MiniShop3\Services\Option\OptionService; +use MiniShop3\Services\Seo\PublicSeoService; use MODX\Revolution\modX; use xPDO\Om\xPDOQuery; @@ -158,6 +159,8 @@ public static function toBool(mixed $value): bool /** * Single published product by ID (same visibility rules as list). * + * Query: context, include_seo (default 1). List payloads omit seo. + * * @param array $params Optional context override * @return array|null */ @@ -184,7 +187,12 @@ public function getById(int $productId, array $params = []): ?array $this->optionService()->loadOptionsForProduct($productId, false) ); - return $this->formatProduct($product, true, $options); + $payload = $this->formatProduct($product, true, $options); + + /** @var PublicSeoService $seo */ + $seo = $this->modx->services->get('ms3_public_seo'); + + return $seo->maybeAttachProduct($payload, $params); } /** diff --git a/core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php b/core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php new file mode 100644 index 000000000..2c749d544 --- /dev/null +++ b/core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php @@ -0,0 +1,142 @@ + */ + public const KEYS = ['title', 'description', 'canonical', 'robots', 'og']; + + /** @var list */ + public const OG_KEYS = ['title', 'description', 'image', 'type']; + + /** + * Absolute URL without double slashes. Already-absolute http(s) and protocol-relative + * paths are returned unchanged. Empty path yields empty string (not site root). + */ + public static function absoluteUrl(string $base, string $path): string + { + $path = trim($path); + if ($path === '') { + return ''; + } + + if (preg_match('#^(https?:)?//#i', $path) === 1) { + return $path; + } + + $base = rtrim(trim($base), '/'); + if ($base === '') { + return '/' . ltrim($path, '/'); + } + + return $base . '/' . ltrim($path, '/'); + } + + /** + * @param array $fields Public catalog payload (pagetitle, uri, image, …) + * @return array{ + * title: string, + * description: string, + * canonical: string, + * robots: string, + * og: array{title: string, description: string, image: string, type: string} + * } + */ + public static function build(array $fields, string $siteUrl, string $ogType): array + { + $title = self::field($fields, 'longtitle', 'pagetitle'); + $description = self::field($fields, 'description', 'introtext'); + $imagePath = self::field($fields, 'image', 'thumb'); + + return [ + 'title' => $title, + 'description' => $description, + 'canonical' => self::absoluteUrl($siteUrl, (string) ($fields['uri'] ?? '')), + 'robots' => 'index,follow', + 'og' => [ + 'title' => $title, + 'description' => $description, + 'image' => self::absoluteUrl($siteUrl, $imagePath), + 'type' => $ogType, + ], + ]; + } + + /** + * @param array $seo + * @return array + */ + public static function whitelist(array $seo): array + { + $out = []; + foreach (self::KEYS as $key) { + if (!array_key_exists($key, $seo)) { + continue; + } + if ($key === 'og') { + if (is_array($seo['og'])) { + $out['og'] = self::stringFields($seo['og'], self::OG_KEYS); + } + continue; + } + if (is_scalar($seo[$key]) || $seo[$key] === null) { + $out[$key] = (string) $seo[$key]; + } + } + + return $out; + } + + /** + * First non-empty trimmed string among payload keys. + * + * @param array $fields + */ + private static function field(array $fields, string ...$keys): string + { + foreach ($keys as $key) { + $value = trim((string) ($fields[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + /** + * @param array $source + * @param list $keys + * @return array + */ + private static function stringFields(array $source, array $keys): array + { + $out = []; + foreach ($keys as $key) { + if (!array_key_exists($key, $source)) { + continue; + } + $value = $source[$key]; + if (is_scalar($value) || $value === null) { + $out[$key] = (string) $value; + } + } + + return $out; + } +} diff --git a/core/components/minishop3/src/Services/Seo/PublicSeoService.php b/core/components/minishop3/src/Services/Seo/PublicSeoService.php new file mode 100644 index 000000000..8c24ad76e --- /dev/null +++ b/core/components/minishop3/src/Services/Seo/PublicSeoService.php @@ -0,0 +1,120 @@ +event->returnedValues['seo'] (assoc patch). Core has no SEO Extra. + */ +final class PublicSeoService +{ + public function __construct( + private modX $modx, + ) { + } + + /** + * @param array $payload + * @param array $params + * @return array + */ + public function maybeAttachProduct(array $payload, array $params): array + { + return $this->maybeAttach($payload, $params, PublicSeoBuilder::OG_TYPE_PRODUCT); + } + + /** + * @param array $payload + * @param array $params + * @return array + */ + public function maybeAttachCategory(array $payload, array $params): array + { + return $this->maybeAttach($payload, $params, PublicSeoBuilder::OG_TYPE_CATEGORY); + } + + /** + * @param array $payload + * @param array $params + * @return array + */ + private function maybeAttach(array $payload, array $params, string $ogType): array + { + if (!CatalogQuery::resolveBool($params, 'include_seo', true)) { + return $payload; + } + + $payload['seo'] = $this->build($payload, $params, $ogType); + + return $payload; + } + + /** + * @param array $payload + * @param array $params + * @return array + */ + private function build(array $payload, array $params, string $ogType): array + { + $seo = PublicSeoBuilder::build($payload, $this->siteUrl($payload, $params), $ogType); + + $event = EventGate::invokeRaw($this->modx, 'msOnGetPublicSeo', [ + 'seo' => $seo, + 'payload' => $payload, + 'og_type' => $ogType, + ]); + $patch = $event['returnedValues']['seo'] ?? null; + $seo = EventGate::applyReturnedArray($seo, $event['returnedValues'], 'seo'); + + return PublicSeoBuilder::whitelist(self::mirrorOgText($seo, $patch)); + } + + /** + * @param array $payload + * @param array $params + */ + 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); + if (is_object($context) && method_exists($context, 'getOption')) { + $url = trim((string) $context->getOption('site_url')); + if ($url !== '') { + return $url; + } + } + + return (string) $this->modx->getOption('site_url', null, ''); + } + + /** + * Keep og.title / og.description in sync with title / description unless the plugin + * explicitly patched those og keys. + * + * @param array $seo + * @return array + */ + private static function mirrorOgText(array $seo, mixed $patch): array + { + $ogPatch = is_array($patch) && isset($patch['og']) && is_array($patch['og']) ? $patch['og'] : []; + $og = isset($seo['og']) && is_array($seo['og']) ? $seo['og'] : []; + if (!array_key_exists('title', $ogPatch)) { + $og['title'] = (string) ($seo['title'] ?? ''); + } + if (!array_key_exists('description', $ogPatch)) { + $og['description'] = (string) ($seo['description'] ?? ''); + } + $seo['og'] = $og; + + return $seo; + } +} diff --git a/core/components/minishop3/tests/PublicSeoRoutesTest.php b/core/components/minishop3/tests/PublicSeoRoutesTest.php new file mode 100644 index 000000000..fa292f78a --- /dev/null +++ b/core/components/minishop3/tests/PublicSeoRoutesTest.php @@ -0,0 +1,90 @@ + 'Kettle', 'longtitle' => ''], + 'https://shop.example/', + PublicSeoBuilder::OG_TYPE_PRODUCT, + ); + + self::assertSame('Kettle', $seo['title']); + self::assertSame('Kettle', $seo['og']['title']); + } + + public function testPrefersLongtitleWhenSet(): void + { + $seo = PublicSeoBuilder::build( + ['pagetitle' => 'Kettle', 'longtitle' => 'Electric kettle'], + 'https://shop.example/', + PublicSeoBuilder::OG_TYPE_PRODUCT, + ); + + self::assertSame('Electric kettle', $seo['title']); + } + + public function testDescriptionFallsBackToIntrotext(): void + { + $seo = PublicSeoBuilder::build( + ['description' => '', 'introtext' => 'Short blurb'], + 'https://shop.example/', + PublicSeoBuilder::OG_TYPE_PRODUCT, + ); + + self::assertSame('Short blurb', $seo['description']); + self::assertSame('Short blurb', $seo['og']['description']); + } + + public function testCanonicalJoinsSiteUrlAndUri(): void + { + $seo = PublicSeoBuilder::build( + ['uri' => 'catalog/kettle/'], + 'https://shop.example/', + PublicSeoBuilder::OG_TYPE_PRODUCT, + ); + + self::assertSame('https://shop.example/catalog/kettle/', $seo['canonical']); + } + + public function testOgImagePrefersImageThenThumb(): void + { + $fromImage = PublicSeoBuilder::build( + ['image' => '/assets/kettle.jpg', 'thumb' => '/assets/kettle_small.jpg'], + 'https://shop.example/', + PublicSeoBuilder::OG_TYPE_PRODUCT, + ); + self::assertSame('https://shop.example/assets/kettle.jpg', $fromImage['og']['image']); + + $fromThumb = PublicSeoBuilder::build( + ['image' => '', 'thumb' => '/assets/kettle_small.jpg'], + 'https://shop.example/', + PublicSeoBuilder::OG_TYPE_PRODUCT, + ); + self::assertSame('https://shop.example/assets/kettle_small.jpg', $fromThumb['og']['image']); + + $empty = PublicSeoBuilder::build( + ['image' => '', 'thumb' => ''], + 'https://shop.example/', + PublicSeoBuilder::OG_TYPE_PRODUCT, + ); + self::assertSame('', $empty['og']['image']); + } + + public function testOgTypeAndDefaultRobots(): void + { + $product = PublicSeoBuilder::build([], 'https://shop.example/', PublicSeoBuilder::OG_TYPE_PRODUCT); + self::assertSame('product', $product['og']['type']); + self::assertSame('index,follow', $product['robots']); + + $category = PublicSeoBuilder::build([], 'https://shop.example/', PublicSeoBuilder::OG_TYPE_CATEGORY); + self::assertSame('website', $category['og']['type']); + } + + public function testWhitelistDropsUnknownKeys(): void + { + $clean = PublicSeoBuilder::whitelist([ + 'title' => 'A', + 'description' => 'B', + 'canonical' => 'https://shop.example/', + 'robots' => 'index,follow', + 'jsonld' => '{"leak":true}', + 'og' => [ + 'title' => 'A', + 'description' => 'B', + 'image' => '', + 'type' => 'product', + 'extra' => 'nope', + ], + ]); + + self::assertSame(['title', 'description', 'canonical', 'robots', 'og'], array_keys($clean)); + self::assertSame(['title', 'description', 'image', 'type'], array_keys($clean['og'])); + self::assertArrayNotHasKey('jsonld', $clean); + self::assertArrayNotHasKey('extra', $clean['og']); + } + + public function testWhitelistDropsNonArrayOg(): void + { + $clean = PublicSeoBuilder::whitelist([ + 'title' => 'A', + 'og' => 'https://evil.example/', + ]); + + self::assertSame('A', $clean['title']); + self::assertArrayNotHasKey('og', $clean); + } + + public function testWhitelistDropsNonScalarTitle(): void + { + $clean = PublicSeoBuilder::whitelist([ + 'title' => ['leak'], + 'robots' => 'index,follow', + ]); + + self::assertArrayNotHasKey('title', $clean); + self::assertSame('index,follow', $clean['robots']); + } +} diff --git a/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoServiceTest.php b/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoServiceTest.php new file mode 100644 index 000000000..0bf190526 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Seo/PublicSeoServiceTest.php @@ -0,0 +1,166 @@ +service()->maybeAttachProduct( + [ + 'pagetitle' => 'Kettle', + 'longtitle' => '', + 'uri' => 'catalog/kettle/', + 'image' => '/assets/kettle.jpg', + ], + [], + ); + + self::assertArrayHasKey('seo', $out); + self::assertSame('Kettle', $out['seo']['title']); + self::assertSame('https://shop.example/catalog/kettle/', $out['seo']['canonical']); + self::assertSame('https://shop.example/assets/kettle.jpg', $out['seo']['og']['image']); + self::assertSame('product', $out['seo']['og']['type']); + self::assertSame('Kettle', $out['pagetitle']); + } + + public function testCategoryOgTypeIsWebsite(): void + { + $out = $this->service()->maybeAttachCategory(['pagetitle' => 'Tea'], []); + + self::assertSame('website', $out['seo']['og']['type']); + self::assertSame('Tea', $out['seo']['title']); + } + + public function testIncludeSeoZeroOmitsKey(): void + { + $out = $this->service()->maybeAttachProduct( + ['pagetitle' => 'Kettle'], + ['include_seo' => 0], + ); + + self::assertArrayNotHasKey('seo', $out); + self::assertSame('Kettle', $out['pagetitle']); + } + + public function testHookTitlePatchMirrorsOgTitle(): void + { + $out = $this->service(['title' => 'Override', 'jsonld' => '{}'])->maybeAttachProduct( + ['pagetitle' => 'Kettle', 'longtitle' => 'Electric kettle'], + [], + ); + + self::assertSame('Override', $out['seo']['title']); + self::assertSame('Override', $out['seo']['og']['title']); + self::assertArrayNotHasKey('jsonld', $out['seo']); + } + + public function testCanonicalUsesContextSiteUrl(): void + { + $out = $this->service(null, ['en' => 'https://en.example/'])->maybeAttachProduct( + [ + 'pagetitle' => 'Kettle', + 'uri' => 'catalog/kettle/', + 'context_key' => 'en', + ], + [], + ); + + self::assertSame('https://en.example/catalog/kettle/', $out['seo']['canonical']); + } + + public function testQueryContextOverridesPayloadContext(): void + { + $out = $this->service(null, [ + 'web' => 'https://shop.example/', + 'en' => 'https://en.example/', + ])->maybeAttachProduct( + [ + 'pagetitle' => 'Kettle', + 'uri' => 'catalog/kettle/', + 'context_key' => 'web', + ], + ['context' => 'en'], + ); + + self::assertSame('https://en.example/catalog/kettle/', $out['seo']['canonical']); + } + + /** + * @param array|null $seoPatch + * @param array $contextUrls + */ + private function service(?array $seoPatch = null, array $contextUrls = []): PublicSeoService + { + return new PublicSeoService($this->modx($seoPatch, $contextUrls)); + } + + /** + * @param array|null $seoPatch + * @param array $contextUrls + */ + private function modx(?array $seoPatch = null, array $contextUrls = []): modX + { + return new class ($seoPatch, $contextUrls) extends modX { + public object $event; + + /** + * @param array|null $seoPatch + * @param array $contextUrls + */ + public function __construct( + private ?array $seoPatch, + private array $contextUrls, + ) { + parent::__construct(); + $this->event = (object) ['returnedValues' => null]; + } + + public function getOption(string $key, $options = null, $default = null) + { + return $key === 'site_url' ? 'https://shop.example/' : $default; + } + + public function getContext($contextKey, $options = null) + { + $url = $this->contextUrls[$contextKey] ?? null; + if ($url === null) { + return null; + } + + return new class ($url) { + public function __construct(private string $url) + { + } + + public function getOption(string $key, $options = null, $default = null) + { + return $key === 'site_url' ? $this->url : $default; + } + }; + } + + public function invokeEvent($eventName, array $params = []) + { + if ($this->seoPatch !== null) { + $this->event->returnedValues = ['seo' => $this->seoPatch]; + } + + return []; + } + }; + } +}