Skip to content
Merged
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
2 changes: 2 additions & 0 deletions _build/elements/events.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions _build/elements/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
2 changes: 2 additions & 0 deletions core/components/minishop3/lexicon/en/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions core/components/minishop3/lexicon/ru/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 бота';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $params
*/
Expand All @@ -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<string, mixed> $params
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $params Route + query params (Router merges $_GET)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class CategoryCatalogService
'publishedon',
'createdon',
'editedon',
'searchable',
];

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

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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,
),
];
}

Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class ProductCatalogService
'publishedon',
'createdon',
'editedon',
'searchable',
];

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

/**
Expand Down Expand Up @@ -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<string, mixed> $params
* @return array{items: list<array<string, mixed>>, total: int, limit: int, offset: int}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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 */
Expand Down
27 changes: 22 additions & 5 deletions core/components/minishop3/src/Services/Seo/PublicSeoBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -102,6 +107,18 @@ public static function whitelist(array $seo): array
return $out;
}

/**
* @param array<string, mixed> $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.
*
Expand Down
Loading