Files
MokoSuiteOpenGraph/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php
T
Jonathan Miller 46e30c950b fix: address PR review findings — error handling and data integrity
- Add missing language field to batch-generated records
- Wrap batch insert in try-catch to handle duplicate key races
- Add logging to all empty catch blocks (script.php, MokoOG license check)
- Guard loadShopProduct() with try-catch for missing MokoSuiteShop tables
- Guard reviews query in JsonLdBuilder for missing #__mokoshop_reviews
2026-06-21 16:26:13 -05:00

268 lines
8.3 KiB
PHP

<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Plugin\System\MokoOG\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Uri\Uri;
class JsonLdBuilder
{
/**
* Build Article schema for a com_content article.
*
* @param int $articleId Article ID
* @param string $title Page title
* @param string $description Page description
* @param string $image Image URL (absolute)
* @param object|null $cachedArticle Pre-loaded article data (avoids duplicate query)
*
* @return array|null
*/
public static function buildArticle(int $articleId, string $title, string $description, string $image, ?object $cachedArticle = null): ?array
{
if ($articleId <= 0) {
return null;
}
$article = $cachedArticle;
if (!$article) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'a.created', 'a.modified', 'a.publish_up',
]))
->select($db->quoteName('u.name', 'author_name'))
->from($db->quoteName('#__content', 'a'))
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
->where($db->quoteName('a.id') . ' = ' . $articleId);
$db->setQuery($query);
$article = $db->loadObject();
}
if (!$article) {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $title,
'description' => $description,
'url' => Uri::getInstance()->toString(),
'datePublished' => $article->publish_up ?: $article->created,
'dateModified' => $article->modified ?: $article->created,
];
$authorName = $article->author_name ?? '';
if (!empty($authorName)) {
$schema['author'] = [
'@type' => 'Person',
'name' => $authorName,
];
}
if (!empty($image)) {
$schema['image'] = $image;
}
return $schema;
}
/**
* Build WebPage schema for non-article pages.
*
* @param string $title Page title
* @param string $description Page description
*
* @return array
*/
public static function buildWebPage(string $title, string $description): array
{
return [
'@context' => 'https://schema.org',
'@type' => 'WebPage',
'name' => $title,
'description' => $description,
'url' => Uri::getInstance()->toString(),
];
}
/**
* Build BreadcrumbList schema from Joomla's pathway.
*
* @return array|null
*/
public static function buildBreadcrumbs(): ?array
{
$app = Factory::getApplication();
$pathway = $app->getPathway();
$items = $pathway->getPathway();
if (empty($items)) {
return null;
}
$listItems = [];
$position = 1;
foreach ($items as $item) {
$url = $item->link;
if ($url && !str_starts_with($url, 'http')) {
$url = rtrim(Uri::root(), '/') . '/' . ltrim($url, '/');
}
$listItems[] = [
'@type' => 'ListItem',
'position' => $position,
'name' => $item->name,
'item' => $url ?: Uri::getInstance()->toString(),
];
$position++;
}
return [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $listItems,
];
}
/**
* Build Organization schema from site configuration.
*
* @param string $siteName Site name
*
* @return array
*/
public static function buildOrganization(string $siteName): array
{
return [
'@context' => 'https://schema.org',
'@type' => 'Organization',
'name' => $siteName,
'url' => Uri::root(),
];
}
/**
* Build Product schema for a MokoSuiteShop product.
*
* @param int $productId CRM product ID
* @param string $title Product title
* @param string $description Product description
* @param string $image Image URL (absolute)
* @param object|null $cachedProduct Pre-loaded product data (avoids duplicate query)
*
* @return array|null
*/
public static function buildProduct(int $productId, string $title, string $description, string $image, ?object $cachedProduct = null): ?array
{
if ($productId <= 0) {
return null;
}
$product = $cachedProduct;
if (!$product) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('p.sku, p.price, p.currency, p.stock_qty')
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
->where($db->quoteName('p.id') . ' = ' . $productId)
->where($db->quoteName('p.published') . ' = 1');
$db->setQuery($query);
$product = $db->loadObject();
}
if (!$product) {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $title,
'description' => $description,
'url' => Uri::getInstance()->toString(),
];
if (!empty($product->sku)) {
$schema['sku'] = $product->sku;
}
if (!empty($image)) {
$schema['image'] = $image;
}
// Offers (pricing and availability)
$availability = ((float) $product->stock_qty > 0)
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock';
$schema['offers'] = [
'@type' => 'Offer',
'price' => number_format((float) $product->price, 2, '.', ''),
'priceCurrency' => $product->currency ?: 'USD',
'availability' => $availability,
'url' => Uri::getInstance()->toString(),
];
// Aggregate rating from reviews if available
try {
$reviewQuery = $db->getQuery(true)
->select('COUNT(*) AS review_count, AVG(rating) AS avg_rating')
->from($db->quoteName('#__mokoshop_reviews'))
->where($db->quoteName('product_id') . ' = ' . $productId)
->where($db->quoteName('status') . ' = ' . $db->quote('approved'));
$db->setQuery($reviewQuery);
$rating = $db->loadObject();
if ($rating && (int) $rating->review_count > 0) {
$schema['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => round((float) $rating->avg_rating, 1),
'reviewCount' => (int) $rating->review_count,
];
}
} catch (\RuntimeException $e) {
// Reviews table may not exist if MokoSuiteShop reviews module not installed
}
return $schema;
}
/**
* Encode a schema array to a JSON-LD script tag string.
*
* @param array $schema Schema data
*
* @return string
*/
public static function toScriptTag(array $schema): string
{
$json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
// Escape </ sequences to prevent XSS via </script> in content data
$json = str_replace('</', '<\/', $json);
return '<script type="application/ld+json">' . $json . '</script>';
}
}