872074cd5b
- FAQ: auto-detects h3/h4 + paragraph patterns, outputs FAQPage (#62) - HowTo: auto-detects ordered lists, outputs HowTo with steps (#63) - Event: per-article fields (dates, venue, tickets), event_data JSON column, outputs Event schema (#64) - Recipe: per-article fields (times, ingredients, nutrition), recipe_data JSON column, outputs Recipe schema (#66) - DB migration 01.04.00: adds event_data and recipe_data columns Closes #62, closes #63, closes #64, closes #66
675 lines
20 KiB
PHP
675 lines
20 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;
|
|
}
|
|
|
|
/**
|
|
* Build VideoObject schema for pages with a video URL.
|
|
*
|
|
* @param string $videoUrl Video URL (e.g. YouTube, Vimeo, or direct)
|
|
* @param string $title Video title
|
|
* @param string $description Video description
|
|
* @param string $imageUrl Thumbnail image URL (absolute)
|
|
*
|
|
* @return array|null
|
|
*/
|
|
public static function buildVideo(string $videoUrl, string $title, string $description, string $imageUrl): ?array
|
|
{
|
|
if (empty($videoUrl)) {
|
|
return null;
|
|
}
|
|
|
|
$schema = [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'VideoObject',
|
|
'name' => $title,
|
|
'description' => $description,
|
|
'thumbnailUrl' => $imageUrl,
|
|
'contentUrl' => $videoUrl,
|
|
'uploadDate' => Factory::getDate()->toISO8601(),
|
|
];
|
|
|
|
// Add embedUrl for YouTube and Vimeo
|
|
if (preg_match('/youtube\.com|youtu\.be|vimeo\.com/i', $videoUrl)) {
|
|
$schema['embedUrl'] = $videoUrl;
|
|
}
|
|
|
|
return $schema;
|
|
}
|
|
|
|
/**
|
|
* Build LocalBusiness schema from plugin parameters.
|
|
*
|
|
* @param object $params Plugin parameters object
|
|
*
|
|
* @return array|null
|
|
*/
|
|
public static function buildLocalBusiness(object $params): ?array
|
|
{
|
|
$name = trim((string) $params->get('lb_name', ''));
|
|
|
|
if ($name === '') {
|
|
return null;
|
|
}
|
|
|
|
$schema = [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => $params->get('lb_type', 'LocalBusiness'),
|
|
'name' => $name,
|
|
];
|
|
|
|
// Build PostalAddress
|
|
$address = [];
|
|
$street = trim((string) $params->get('lb_street', ''));
|
|
$city = trim((string) $params->get('lb_city', ''));
|
|
$region = trim((string) $params->get('lb_region', ''));
|
|
$postal = trim((string) $params->get('lb_postal', ''));
|
|
$country = trim((string) $params->get('lb_country', ''));
|
|
|
|
if ($street !== '') {
|
|
$address['streetAddress'] = $street;
|
|
}
|
|
|
|
if ($city !== '') {
|
|
$address['addressLocality'] = $city;
|
|
}
|
|
|
|
if ($region !== '') {
|
|
$address['addressRegion'] = $region;
|
|
}
|
|
|
|
if ($postal !== '') {
|
|
$address['postalCode'] = $postal;
|
|
}
|
|
|
|
if ($country !== '') {
|
|
$address['addressCountry'] = $country;
|
|
}
|
|
|
|
if (!empty($address)) {
|
|
$address['@type'] = 'PostalAddress';
|
|
$schema['address'] = $address;
|
|
}
|
|
|
|
// Contact properties
|
|
$phone = trim((string) $params->get('lb_phone', ''));
|
|
$email = trim((string) $params->get('lb_email', ''));
|
|
$url = trim((string) $params->get('lb_url', ''));
|
|
|
|
if ($phone !== '') {
|
|
$schema['telephone'] = $phone;
|
|
}
|
|
|
|
if ($email !== '') {
|
|
$schema['email'] = $email;
|
|
}
|
|
|
|
if ($url !== '') {
|
|
$schema['url'] = $url;
|
|
}
|
|
|
|
// Opening hours
|
|
$openingHours = trim((string) $params->get('lb_opening_hours', ''));
|
|
|
|
if ($openingHours !== '') {
|
|
$schema['openingHours'] = $openingHours;
|
|
}
|
|
|
|
// GeoCoordinates
|
|
$latitude = trim((string) $params->get('lb_latitude', ''));
|
|
$longitude = trim((string) $params->get('lb_longitude', ''));
|
|
|
|
if ($latitude !== '' && $longitude !== '') {
|
|
$schema['geo'] = [
|
|
'@type' => 'GeoCoordinates',
|
|
'latitude' => $latitude,
|
|
'longitude' => $longitude,
|
|
];
|
|
}
|
|
|
|
// Price range
|
|
$priceRange = trim((string) $params->get('lb_price_range', ''));
|
|
|
|
if ($priceRange !== '') {
|
|
$schema['priceRange'] = $priceRange;
|
|
}
|
|
|
|
return $schema;
|
|
}
|
|
|
|
/**
|
|
* Build FAQPage schema from question/answer pairs.
|
|
*
|
|
* @param array $questions Array of ['question' => '...', 'answer' => '...'] pairs
|
|
*
|
|
* @return array|null
|
|
*/
|
|
public static function buildFaq(array $questions): ?array
|
|
{
|
|
if (empty($questions)) {
|
|
return null;
|
|
}
|
|
|
|
$mainEntity = [];
|
|
|
|
foreach ($questions as $item) {
|
|
$question = trim($item['question'] ?? '');
|
|
$answer = trim($item['answer'] ?? '');
|
|
|
|
if ($question === '' || $answer === '') {
|
|
continue;
|
|
}
|
|
|
|
$mainEntity[] = [
|
|
'@type' => 'Question',
|
|
'name' => $question,
|
|
'acceptedAnswer' => [
|
|
'@type' => 'Answer',
|
|
'text' => $answer,
|
|
],
|
|
];
|
|
}
|
|
|
|
if (empty($mainEntity)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'FAQPage',
|
|
'mainEntity' => $mainEntity,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build HowTo schema from step-by-step instructions.
|
|
*
|
|
* @param string $title HowTo title
|
|
* @param array $steps Array of ['name' => 'Step title', 'text' => 'Step instructions']
|
|
* @param string $imageUrl Optional image URL (absolute)
|
|
*
|
|
* @return array|null
|
|
*/
|
|
public static function buildHowTo(string $title, array $steps, string $imageUrl = ''): ?array
|
|
{
|
|
if (empty($steps)) {
|
|
return null;
|
|
}
|
|
|
|
$schema = [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'HowTo',
|
|
'name' => $title,
|
|
];
|
|
|
|
if (!empty($imageUrl)) {
|
|
$schema['image'] = $imageUrl;
|
|
}
|
|
|
|
$schema['step'] = [];
|
|
|
|
foreach ($steps as $step) {
|
|
$schema['step'][] = [
|
|
'@type' => 'HowToStep',
|
|
'name' => $step['name'],
|
|
'text' => $step['text'],
|
|
];
|
|
}
|
|
|
|
return $schema;
|
|
}
|
|
|
|
/**
|
|
* Build Event schema from per-article event data.
|
|
*
|
|
* @param string $title Event/article title
|
|
* @param string $description Event description
|
|
* @param string $imageUrl Image URL (absolute)
|
|
* @param object $eventData Decoded event_data with event_start, event_end, etc.
|
|
*
|
|
* @return array|null
|
|
*/
|
|
public static function buildEvent(string $title, string $description, string $imageUrl, object $eventData): ?array
|
|
{
|
|
$startDate = $eventData->event_start ?? '';
|
|
|
|
if (empty($startDate)) {
|
|
return null;
|
|
}
|
|
|
|
$schema = [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'Event',
|
|
'name' => $title,
|
|
'description' => $description,
|
|
'startDate' => $startDate,
|
|
'url' => Uri::getInstance()->toString(),
|
|
];
|
|
|
|
$endDate = $eventData->event_end ?? '';
|
|
|
|
if (!empty($endDate)) {
|
|
$schema['endDate'] = $endDate;
|
|
}
|
|
|
|
if (!empty($imageUrl)) {
|
|
$schema['image'] = $imageUrl;
|
|
}
|
|
|
|
$locationName = $eventData->event_location ?? '';
|
|
$address = $eventData->event_address ?? '';
|
|
|
|
if (!empty($locationName) || !empty($address)) {
|
|
$location = ['@type' => 'Place'];
|
|
|
|
if (!empty($locationName)) {
|
|
$location['name'] = $locationName;
|
|
}
|
|
|
|
if (!empty($address)) {
|
|
$location['address'] = [
|
|
'@type' => 'PostalAddress',
|
|
'streetAddress' => $address,
|
|
];
|
|
}
|
|
|
|
$schema['location'] = $location;
|
|
}
|
|
|
|
$price = $eventData->event_price ?? '';
|
|
$currency = $eventData->event_currency ?? 'USD';
|
|
$ticketUrl = $eventData->event_url ?? '';
|
|
|
|
if ($price !== '') {
|
|
$offer = [
|
|
'@type' => 'Offer',
|
|
'price' => number_format((float) $price, 2, '.', ''),
|
|
'priceCurrency' => $currency ?: 'USD',
|
|
'availability' => 'https://schema.org/InStock',
|
|
];
|
|
|
|
if (!empty($ticketUrl)) {
|
|
$offer['url'] = $ticketUrl;
|
|
}
|
|
|
|
$schema['offers'] = $offer;
|
|
} elseif (!empty($ticketUrl)) {
|
|
$schema['offers'] = [
|
|
'@type' => 'Offer',
|
|
'price' => '0.00',
|
|
'priceCurrency' => $currency ?: 'USD',
|
|
'availability' => 'https://schema.org/InStock',
|
|
'url' => $ticketUrl,
|
|
];
|
|
}
|
|
|
|
return $schema;
|
|
}
|
|
|
|
/**
|
|
* Build Recipe schema from per-article recipe data.
|
|
*
|
|
* @param string $title Recipe/article title
|
|
* @param string $description Recipe/article description
|
|
* @param string $imageUrl Image URL (absolute)
|
|
* @param object $recipeData Decoded recipe_data object
|
|
*
|
|
* @return array|null
|
|
*/
|
|
public static function buildRecipe(string $title, string $description, string $imageUrl, object $recipeData): ?array
|
|
{
|
|
$fields = ['recipe_prep_time', 'recipe_cook_time', 'recipe_yield', 'recipe_calories', 'recipe_ingredients', 'recipe_category', 'recipe_cuisine'];
|
|
$hasData = false;
|
|
|
|
foreach ($fields as $field) {
|
|
if (!empty($recipeData->$field)) {
|
|
$hasData = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$hasData) {
|
|
return null;
|
|
}
|
|
|
|
$schema = [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'Recipe',
|
|
'name' => $title,
|
|
'description' => $description,
|
|
'url' => Uri::getInstance()->toString(),
|
|
];
|
|
|
|
if (!empty($imageUrl)) {
|
|
$schema['image'] = $imageUrl;
|
|
}
|
|
|
|
if (!empty($recipeData->recipe_prep_time)) {
|
|
$schema['prepTime'] = $recipeData->recipe_prep_time;
|
|
}
|
|
|
|
if (!empty($recipeData->recipe_cook_time)) {
|
|
$schema['cookTime'] = $recipeData->recipe_cook_time;
|
|
}
|
|
|
|
if (!empty($recipeData->recipe_prep_time) && !empty($recipeData->recipe_cook_time)) {
|
|
try {
|
|
$prep = new \DateInterval($recipeData->recipe_prep_time);
|
|
$cook = new \DateInterval($recipeData->recipe_cook_time);
|
|
$totalMinutes = ($prep->h * 60 + $prep->i) + ($cook->h * 60 + $cook->i);
|
|
$hours = intdiv($totalMinutes, 60);
|
|
$minutes = $totalMinutes % 60;
|
|
$totalTime = 'PT';
|
|
|
|
if ($hours > 0) {
|
|
$totalTime .= $hours . 'H';
|
|
}
|
|
|
|
if ($minutes > 0) {
|
|
$totalTime .= $minutes . 'M';
|
|
}
|
|
|
|
if ($totalTime !== 'PT') {
|
|
$schema['totalTime'] = $totalTime;
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Invalid duration format
|
|
}
|
|
}
|
|
|
|
if (!empty($recipeData->recipe_yield)) {
|
|
$schema['recipeYield'] = $recipeData->recipe_yield;
|
|
}
|
|
|
|
if (!empty($recipeData->recipe_calories)) {
|
|
$schema['nutrition'] = [
|
|
'@type' => 'NutritionInformation',
|
|
'calories' => $recipeData->recipe_calories . ' calories',
|
|
];
|
|
}
|
|
|
|
if (!empty($recipeData->recipe_ingredients)) {
|
|
$ingredients = array_filter(
|
|
array_map('trim', preg_split('/\r\n|\r|\n/', $recipeData->recipe_ingredients)),
|
|
fn($line) => $line !== ''
|
|
);
|
|
|
|
if (!empty($ingredients)) {
|
|
$schema['recipeIngredient'] = array_values($ingredients);
|
|
}
|
|
}
|
|
|
|
if (!empty($recipeData->recipe_category)) {
|
|
$schema['recipeCategory'] = $recipeData->recipe_category;
|
|
}
|
|
|
|
if (!empty($recipeData->recipe_cuisine)) {
|
|
$schema['recipeCuisine'] = $recipeData->recipe_cuisine;
|
|
}
|
|
|
|
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>';
|
|
}
|
|
}
|