46e30c950b
- 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
268 lines
8.3 KiB
PHP
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>';
|
|
}
|
|
}
|