feat: add custom schema, AI generation, XML sitemap, platform images
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s

- Custom JSON-LD schema builder: per-article textarea for arbitrary
  structured data with JSON validation. Closes #70
- AI-powered meta generation: Generate with AI buttons for OG title
  and description, supports Claude and OpenAI APIs. Closes #71
- XML sitemap: auto-generates sitemap.xml on article save, respects
  noindex directives. Closes #72
- Per-platform image resizing: Twitter 1200x600, Pinterest 1000x1500,
  WhatsApp 400x400 alongside default Facebook 1200x630. Closes #74
- DB migration 01.05.00: adds custom_schema column
This commit is contained in:
Jonathan Miller
2026-06-23 12:53:47 -05:00
parent cbebaecc22
commit 49d644566a
13 changed files with 534 additions and 3 deletions
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` (
`og_video` VARCHAR(512) NOT NULL DEFAULT '',
`event_data` TEXT NULL,
`recipe_data` TEXT NULL,
`custom_schema` TEXT NULL,
`seo_title` VARCHAR(70) NOT NULL DEFAULT '',
`meta_description` VARCHAR(200) NOT NULL DEFAULT '',
`robots` VARCHAR(100) NOT NULL DEFAULT '',
@@ -0,0 +1 @@
ALTER TABLE `#__mokoog_tags` ADD COLUMN `custom_schema` TEXT NULL AFTER `canonical_url`;
@@ -121,5 +121,9 @@
<field name="recipe_category" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC" filter="string" hint="Dessert" />
<field name="recipe_cuisine" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC" filter="string" hint="Italian" />
</fieldset>
<fieldset name="mokoog_custom_schema" label="PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL"
description="PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC">
<field name="custom_schema" type="textarea" label="PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA" description="PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC" filter="raw" rows="12" class="input-xxlarge" />
</fieldset>
</fields>
</form>
@@ -63,3 +63,8 @@ PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL="Custom Schema"
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC="Add custom JSON-LD structured data for this page."
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA="Custom JSON-LD"
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC="Enter valid JSON-LD structured data. The @context will be added automatically if missing. Use for schema types not covered by built-in options (e.g. Course, JobPosting, SoftwareApplication)."
@@ -62,4 +62,9 @@ PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC="One ingredient per line."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
PLG_CONTENT_MKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL="Custom Schema"
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC="Add custom JSON-LD structured data for this page."
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA="Custom JSON-LD"
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC="Enter valid JSON-LD structured data. The @context will be added automatically if missing. Use for schema types not covered by built-in options (e.g. Course, JobPosting, SoftwareApplication)."
@@ -53,6 +53,49 @@ document.addEventListener('DOMContentLoaded', function () {
refresh();
});
// AI Generate buttons
['ogTitle', 'ogDesc'].forEach(function(fieldKey) {
var field = fields[fieldKey];
if (!field) return;
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-primary mokoog-ai-btn';
btn.textContent = 'Generate with AI';
btn.dataset.target = fieldKey;
field.parentNode.appendChild(btn);
btn.addEventListener('click', function() {
var articleTitle = fields.articleTitle ? fields.articleTitle.value : '';
btn.disabled = true;
btn.textContent = 'Generating...';
var formData = new FormData();
formData.append('task', 'mokoog.aiGenerate');
formData.append('field', fieldKey === 'ogTitle' ? 'title' : 'description');
formData.append('article_title', articleTitle);
formData.append(Joomla.getOptions('csrf.token'), 1);
fetch(window.location.origin + '/administrator/index.php?option=com_ajax&plugin=mokoog&group=system&format=json', {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.data && data.data[0]) {
field.value = data.data[0];
field.dispatchEvent(new Event('input'));
}
btn.disabled = false;
btn.textContent = 'Generate with AI';
})
.catch(function() {
btn.disabled = false;
btn.textContent = 'Generate with AI';
});
});
});
// Find the mokoog fieldset and insert preview after it
var fieldset = document.querySelector('[data-showon-id="mokoog"]') ||
document.getElementById('attrib-mokoog') ||
@@ -212,7 +212,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$query = $db->getQuery(true)
->select($db->quoteName([
'og_title', 'og_description', 'og_image', 'og_type', 'og_video',
'event_data', 'recipe_data',
'event_data', 'recipe_data', 'custom_schema',
'seo_title', 'meta_description', 'robots', 'canonical_url',
]))
->from($db->quoteName('#__mokoog_tags'))
@@ -270,6 +270,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
'og_video' => $this->sanitizeUrl($ogData['og_video'] ?? ''),
'event_data' => $this->packJsonFields($ogData, ['event_start', 'event_end', 'event_location', 'event_address', 'event_price', 'event_currency', 'event_url']),
'recipe_data' => $this->packJsonFields($ogData, ['recipe_prep_time', 'recipe_cook_time', 'recipe_yield', 'recipe_calories', 'recipe_ingredients', 'recipe_category', 'recipe_cuisine']),
'custom_schema' => $this->validateJson($ogData['custom_schema'] ?? ''),
'seo_title' => strip_tags(trim($ogData['seo_title'] ?? '')),
'meta_description' => strip_tags(trim($ogData['meta_description'] ?? '')),
'robots' => trim($robots),
@@ -310,6 +311,24 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
return !empty($data) ? json_encode($data) : '';
}
/**
* Validate a JSON string — returns trimmed JSON or empty string if invalid.
*
* @param string $json Raw JSON input
*
* @return string
*/
private function validateJson(string $json): string
{
$json = trim($json);
if ($json === '' || json_decode($json) === null) {
return '';
}
return $json;
}
/**
* Sanitize a URL to only allow http/https schemes.
*
@@ -75,3 +75,21 @@ PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE="Longitude"
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC="Geographic longitude of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE="Price Range"
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC="Price range indicator (e.g. $, $$, $$$)."
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE="Per-platform Image Sizes"
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC="Generate platform-specific image sizes (Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400) in addition to the default Facebook 1200x630."
PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP="XML Sitemap"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED="Enable Sitemap"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC="Auto-generate sitemap.xml when articles are saved. Respects noindex robots directives."
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ="Change Frequency"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC="Default change frequency for sitemap entries."
PLG_SYSTEM_MOKOOG_FIELDSET_AI="AI Meta Generation"
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED="Enable AI Generation"
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC="Show Generate with AI buttons next to OG title and description fields."
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER="AI Provider"
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC="Select the AI API provider."
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY="API Key"
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC="Your AI provider API key."
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL="Model"
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC="AI model to use for generation."
@@ -75,3 +75,21 @@ PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE="Longitude"
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC="Geographic longitude of your business."
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE="Price Range"
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC="Price range indicator (e.g. $, $$, $$$)."
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE="Per-platform Image Sizes"
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC="Generate platform-specific image sizes (Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400) in addition to the default Facebook 1200x630."
PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP="XML Sitemap"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED="Enable Sitemap"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC="Auto-generate sitemap.xml when articles are saved. Respects noindex robots directives."
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ="Change Frequency"
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC="Default change frequency for sitemap entries."
PLG_SYSTEM_MOKOOG_FIELDSET_AI="AI Meta Generation"
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED="Enable AI Generation"
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC="Show Generate with AI buttons next to OG title and description fields."
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER="AI Provider"
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC="Select the AI API provider."
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY="API Key"
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC="Your AI provider API key."
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL="Model"
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC="AI model to use for generation."
@@ -158,6 +158,17 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="platform_resize"
type="radio"
label="PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE"
description="PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="jsonld_enabled"
type="radio"
@@ -332,6 +343,29 @@
filter="string"
/>
</fieldset>
<fieldset name="sitemap" label="PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP">
<field name="sitemap_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC" default="0" class="btn-group">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="sitemap_changefreq" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC" default="weekly">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</field>
</fieldset>
<fieldset name="ai" label="PLG_SYSTEM_MOKOOG_FIELDSET_AI">
<field name="ai_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC" default="0" class="btn-group">
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field name="ai_provider" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER" description="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC" default="claude">
<option value="claude">Claude (Anthropic)</option>
<option value="openai">OpenAI</option>
</field>
<field name="ai_api_key" type="password" label="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY" description="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC" filter="string" />
<field name="ai_model" type="text" label="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL" description="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC" default="claude-haiku-4-5-20251001" filter="string" />
</fieldset>
</fields>
</config>
</extension>
@@ -19,6 +19,7 @@ use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\Plugin\System\MokoOG\Helper\ImageHelper;
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
use Joomla\Plugin\System\MokoOG\Helper\SitemapBuilder;
final class MokoOG extends CMSPlugin implements SubscriberInterface
{
@@ -37,6 +38,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return [
'onAfterRoute' => 'onAfterRoute',
'onBeforeCompileHead' => 'onBeforeCompileHead',
'onContentAfterSave' => 'onContentAfterSaveRebuildSitemap',
'onAjaxMokoog' => 'onAjaxMokoog',
];
}
@@ -156,7 +159,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
$doc->setMetaData('twitter:description', $description);
if ($image) {
$doc->setMetaData('twitter:image', $this->resolveImageUrl($image));
$twitterImage = ($this->params->get('auto_resize', 1) && $this->params->get('platform_resize', 0))
? ImageHelper::resizeForPlatform($image, 'twitter')
: $image;
$doc->setMetaData('twitter:image', $this->resolveImageUrl($twitterImage));
}
if ($twitterSite) {
@@ -346,6 +352,21 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
}
}
// Custom JSON-LD schema (user-provided)
$customSchema = $ogData->custom_schema ?? '';
if (!empty($customSchema)) {
$decoded = json_decode($customSchema, true);
if ($decoded) {
if (empty($decoded['@context'])) {
$decoded['@context'] = 'https://schema.org';
}
$doc->addCustomTag(JsonLdBuilder::toScriptTag($decoded));
}
}
if ($this->params->get('jsonld_breadcrumbs', 1)) {
$breadcrumbs = JsonLdBuilder::buildBreadcrumbs();
@@ -428,6 +449,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
'og_video' => '',
'event_data' => '',
'recipe_data' => '',
'custom_schema' => '',
'seo_title' => '',
'meta_description' => '',
'robots' => '',
@@ -789,6 +811,129 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return $steps;
}
/**
* Rebuild sitemap.xml when article content is saved.
*
* @param Event $event The event
*
* @return void
*/
public function onContentAfterSaveRebuildSitemap(Event $event): void
{
if (!$this->params->get('sitemap_enabled', 0)) {
return;
}
[$context] = array_values($event->getArguments());
if ($context !== 'com_content.article') {
return;
}
$changefreq = $this->params->get('sitemap_changefreq', 'weekly');
$xml = SitemapBuilder::generate($changefreq);
SitemapBuilder::writeToFile($xml);
}
/**
* Handle AJAX requests for AI meta tag generation.
*
* @param Event $event The event
*
* @return void
*/
public function onAjaxMokoog(Event $event): void
{
$app = $this->getApplication();
if (!$app->isClient('administrator')) {
return;
}
\Joomla\CMS\Session\Session::checkToken() or die('Invalid Token');
if (!$this->params->get('ai_enabled', 0)) {
$event->setArgument('result', ['AI generation is not enabled']);
return;
}
$apiKey = $this->params->get('ai_api_key', '');
$provider = $this->params->get('ai_provider', 'claude');
$model = $this->params->get('ai_model', 'claude-haiku-4-5-20251001');
if (empty($apiKey)) {
$event->setArgument('result', ['API key not configured']);
return;
}
$input = $app->getInput();
$field = $input->getString('field', 'title');
$articleTitle = $input->getString('article_title', '');
$prompt = $field === 'title'
? "Generate a concise, engaging social media sharing title (max 60 characters) for an article titled: \"$articleTitle\". Return only the title text, no quotes or explanation."
: "Generate a compelling social media sharing description (max 155 characters) for an article titled: \"$articleTitle\". Return only the description text, no quotes or explanation.";
try {
$result = $this->callAiApi($provider, $apiKey, $model, $prompt);
$event->setArgument('result', [$result]);
} catch (\Exception $e) {
$event->setArgument('result', ['Error: ' . $e->getMessage()]);
}
}
/**
* Call an AI API (Claude or OpenAI) with a prompt.
*
* @param string $provider Provider name (claude or openai)
* @param string $apiKey API key
* @param string $model Model name
* @param string $prompt Prompt text
*
* @return string Generated text
*/
private function callAiApi(string $provider, string $apiKey, string $model, string $prompt): string
{
$http = \Joomla\CMS\Http\HttpFactory::getHttp();
if ($provider === 'claude') {
$response = $http->post(
'https://api.anthropic.com/v1/messages',
json_encode([
'model' => $model,
'max_tokens' => 200,
'messages' => [['role' => 'user', 'content' => $prompt]],
]),
[
'Content-Type' => 'application/json',
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
]
);
$data = json_decode($response->body, true);
return trim($data['content'][0]['text'] ?? '');
}
$response = $http->post(
'https://api.openai.com/v1/chat/completions',
json_encode([
'model' => $model,
'max_tokens' => 200,
'messages' => [['role' => 'user', 'content' => $prompt]],
]),
[
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $apiKey,
]
);
$data = json_decode($response->body, true);
return trim($data['choices'][0]['message']['content'] ?? '');
}
/**
* Warn administrators once per session when no license key is configured.
*
@@ -149,6 +149,137 @@ class ImageHelper
return $outputRel;
}
/**
* Resize an image for a specific platform.
*
* @param string $imagePath Relative image path
* @param string $platform Platform name (facebook, twitter, pinterest, whatsapp)
*
* @return string Path to the resized image
*/
public static function resizeForPlatform(string $imagePath, string $platform): string
{
$sizes = [
'facebook' => ['width' => 1200, 'height' => 630],
'twitter' => ['width' => 1200, 'height' => 600],
'pinterest' => ['width' => 1000, 'height' => 1500],
'whatsapp' => ['width' => 400, 'height' => 400],
];
if (!isset($sizes[$platform])) {
return self::resize($imagePath);
}
$size = $sizes[$platform];
return self::resizeToSize($imagePath, $size['width'], $size['height'], $platform);
}
/**
* Resize an image to specific dimensions with a platform-specific subdirectory.
*
* @param string $imagePath Image path relative to JPATH_ROOT
* @param int $width Target width
* @param int $height Target height
* @param string $subdir Subdirectory name for output (e.g. platform name)
*
* @return string Path to the output image (relative to JPATH_ROOT)
*/
private static function resizeToSize(string $imagePath, int $width, int $height, string $subdir = ''): string
{
// Resolve absolute path
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
if (!is_file($absPath)) {
return $imagePath;
}
$imageInfo = getimagesize($absPath);
if (!$imageInfo) {
Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog');
return $imagePath;
}
[$origWidth, $origHeight, $type] = $imageInfo;
// Skip if already at or below target size
if ($origWidth <= $width && $origHeight <= $height) {
return $imagePath;
}
// Build output directory with optional subdirectory
$outputRelDir = self::OUTPUT_DIR . ($subdir ? '/' . $subdir : '');
$outputDir = JPATH_ROOT . '/' . $outputRelDir;
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . $outputRelDir, Log::WARNING, 'mokoog');
return $imagePath;
}
// Generate output filename based on source hash + dimensions
$hash = md5($imagePath . $width . $height);
$outputName = $hash . '.jpg';
$outputPath = $outputDir . '/' . $outputName;
$outputRel = $outputRelDir . '/' . $outputName;
// Skip if already generated
if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) {
return $outputRel;
}
// Load source image
$source = self::loadImage($absPath, $type);
if (!$source) {
return $imagePath;
}
// Calculate crop dimensions (center crop to target aspect ratio)
$targetRatio = $width / $height;
$sourceRatio = $origWidth / $origHeight;
if ($sourceRatio > $targetRatio) {
// Source is wider — crop sides
$cropHeight = $origHeight;
$cropWidth = (int) round($origHeight * $targetRatio);
$cropX = (int) round(($origWidth - $cropWidth) / 2);
$cropY = 0;
} else {
// Source is taller — crop top/bottom
$cropWidth = $origWidth;
$cropHeight = (int) round($origWidth / $targetRatio);
$cropX = 0;
$cropY = (int) round(($origHeight - $cropHeight) / 2);
}
// Create output canvas and resample
$output = imagecreatetruecolor($width, $height);
imagecopyresampled(
$output,
$source,
0,
0,
$cropX,
$cropY,
$width,
$height,
$cropWidth,
$cropHeight
);
// Save as JPEG
imagejpeg($output, $outputPath, self::JPEG_QUALITY);
imagedestroy($source);
imagedestroy($output);
return $outputRel;
}
/**
* Remove a generated image file.
*
@@ -0,0 +1,107 @@
<?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;
/**
* XML Sitemap builder.
*
* Generates a sitemap.xml containing all published articles, excluding
* those marked with noindex robots directives in the mokoog_tags table.
*/
class SitemapBuilder
{
/**
* Generate sitemap XML content.
*
* @param string $changefreq Default change frequency for entries
*
* @return string Complete sitemap XML
*/
public static function generate(string $changefreq = 'weekly'): string
{
$db = Factory::getDbo();
// Get all published articles
$query = $db->getQuery(true)
->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language']))
->from($db->quoteName('#__content', 'a'))
->where($db->quoteName('a.state') . ' = 1');
$db->setQuery($query);
$articles = $db->loadObjectList();
// Get noindex articles from mokoog_tags
$noindexQuery = $db->getQuery(true)
->select($db->quoteName('content_id'))
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('robots') . ' LIKE ' . $db->quote('%noindex%'));
$db->setQuery($noindexQuery);
$noindexIds = $db->loadColumn();
$root = rtrim(Uri::root(), '/');
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
// Homepage
$xml .= ' <url>' . "\n";
$xml .= ' <loc>' . $root . '/</loc>' . "\n";
$xml .= ' <changefreq>daily</changefreq>' . "\n";
$xml .= ' <priority>1.0</priority>' . "\n";
$xml .= ' </url>' . "\n";
foreach ($articles as $article) {
// Skip noindexed
if (in_array((int) $article->id, $noindexIds)) {
continue;
}
$url = $root . '/index.php?option=com_content&view=article&id=' . $article->id;
$lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00'
? date('Y-m-d', strtotime($article->modified)) : '';
$xml .= ' <url>' . "\n";
$xml .= ' <loc>' . htmlspecialchars($url, ENT_XML1) . '</loc>' . "\n";
if ($lastmod) {
$xml .= ' <lastmod>' . $lastmod . '</lastmod>' . "\n";
}
$xml .= ' <changefreq>' . $changefreq . '</changefreq>' . "\n";
$xml .= ' <priority>0.8</priority>' . "\n";
$xml .= ' </url>' . "\n";
}
$xml .= '</urlset>';
return $xml;
}
/**
* Write sitemap XML to the site root.
*
* @param string $xml The sitemap XML content
*
* @return bool True on success
*/
public static function writeToFile(string $xml): bool
{
$path = JPATH_ROOT . '/sitemap.xml';
return (bool) file_put_contents($path, $xml);
}
}