Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions _build/elements/events.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
'msOnGetProductPrice',
'msOnGetProductWeight',
'msOnGetProductFields',
'msOnGetPublicSeo',

// msProducts snippet events (for extending with external packages)
'msOnProductsLoad', // After loading products, for bulk data loading
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $params
*/
public function get(array $params = []): Response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public function __construct(modX $modx)
/**
* GET /api/v1/product/get/{id}
*
* Query: context, include_seo (default 1).
*
* @param array<string, mixed> $params
*/
public function get(array $params = []): Response
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/src/ServiceRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions core/components/minishop3/src/ServiceRegistryFactories.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string, mixed> $params
* @return array<string, mixed>|null
*/
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string, mixed> $params Optional context override
* @return array<string, mixed>|null
*/
Expand All @@ -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);
}

/**
Expand Down
142 changes: 142 additions & 0 deletions core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Seo;

/**
* Core SEO projection for public catalog JSON (#567).
*
* Fallbacks: title ← longtitle|pagetitle; description ← description|introtext;
* canonical / og.image ← absolute site_url + uri/image|thumb; robots = index,follow.
* 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.
*/
final class PublicSeoBuilder
{
public const OG_TYPE_PRODUCT = 'product';
public const OG_TYPE_CATEGORY = 'website';

/** @var list<string> */
public const KEYS = ['title', 'description', 'canonical', 'robots', 'og'];

/** @var list<string> */
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<string, mixed> $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<string, mixed> $seo
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $source
* @param list<string> $keys
* @return array<string, string>
*/
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;
}
}
120 changes: 120 additions & 0 deletions core/components/minishop3/src/Services/Seo/PublicSeoService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Seo;

use MiniShop3\Services\Catalog\CatalogQuery;
use MiniShop3\Utils\EventGate;
use MODX\Revolution\modX;

/**
* Attach allowlisted `seo` to a public catalog payload (#567).
*
* Optional enrichment: plugins on msOnGetPublicSeo may set
* $modx->event->returnedValues['seo'] (assoc patch). Core has no SEO Extra.
*/
final class PublicSeoService
{
public function __construct(
private modX $modx,
) {
}

/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
public function maybeAttachProduct(array $payload, array $params): array
{
return $this->maybeAttach($payload, $params, PublicSeoBuilder::OG_TYPE_PRODUCT);
}

/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
public function maybeAttachCategory(array $payload, array $params): array
{
return $this->maybeAttach($payload, $params, PublicSeoBuilder::OG_TYPE_CATEGORY);
}

/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @param array<string, mixed> $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<string, mixed> $seo
* @return array<string, mixed>
*/
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;
}
}
Loading