diff --git a/source/packages/com_mokoog/sql/install.mysql.sql b/source/packages/com_mokoog/sql/install.mysql.sql
index 86e2c45..ac3112c 100644
--- a/source/packages/com_mokoog/sql/install.mysql.sql
+++ b/source/packages/com_mokoog/sql/install.mysql.sql
@@ -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 '',
diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.05.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.05.00.sql
new file mode 100644
index 0000000..283e14e
--- /dev/null
+++ b/source/packages/com_mokoog/sql/updates/mysql/01.05.00.sql
@@ -0,0 +1 @@
+ALTER TABLE `#__mokoog_tags` ADD COLUMN `custom_schema` TEXT NULL AFTER `canonical_url`;
diff --git a/source/packages/plg_content_mokoog/forms/mokoog.xml b/source/packages/plg_content_mokoog/forms/mokoog.xml
index 08635ce..6dd2811 100644
--- a/source/packages/plg_content_mokoog/forms/mokoog.xml
+++ b/source/packages/plg_content_mokoog/forms/mokoog.xml
@@ -121,5 +121,9 @@
+
diff --git a/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini
index 85fe8f2..1ade0e2 100644
--- a/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini
+++ b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini
@@ -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)."
diff --git a/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini
index 9a7634a..1ade0e2 100644
--- a/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini
+++ b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini
@@ -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)."
diff --git a/source/packages/plg_content_mokoog/media/js/preview.js b/source/packages/plg_content_mokoog/media/js/preview.js
index 77192b2..9cbc401 100644
--- a/source/packages/plg_content_mokoog/media/js/preview.js
+++ b/source/packages/plg_content_mokoog/media/js/preview.js
@@ -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') ||
diff --git a/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php b/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php
index 9d93f0e..dd7d709 100644
--- a/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php
+++ b/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php
@@ -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.
*
diff --git a/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini
index b6df7cb..3791562 100644
--- a/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini
+++ b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini
@@ -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."
diff --git a/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini
index b6df7cb..3791562 100644
--- a/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini
+++ b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini
@@ -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."
diff --git a/source/packages/plg_system_mokoog/mokoog.xml b/source/packages/plg_system_mokoog/mokoog.xml
index 54d956f..aa97e32 100644
--- a/source/packages/plg_system_mokoog/mokoog.xml
+++ b/source/packages/plg_system_mokoog/mokoog.xml
@@ -158,6 +158,17 @@
+
+
+
+
+
+
diff --git a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php
index 099a6c9..bb59693 100644
--- a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php
+++ b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php
@@ -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.
*
diff --git a/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php
index 6bdca69..2aaf5e9 100644
--- a/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php
+++ b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php
@@ -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.
*
diff --git a/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php b/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php
new file mode 100644
index 0000000..70068e7
--- /dev/null
+++ b/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php
@@ -0,0 +1,107 @@
+
+ * @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 = '' . "\n";
+ $xml .= '' . "\n";
+
+ // Homepage
+ $xml .= ' ' . "\n";
+ $xml .= ' ' . $root . '/' . "\n";
+ $xml .= ' daily' . "\n";
+ $xml .= ' 1.0' . "\n";
+ $xml .= ' ' . "\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 .= ' ' . "\n";
+ $xml .= ' ' . htmlspecialchars($url, ENT_XML1) . '' . "\n";
+
+ if ($lastmod) {
+ $xml .= ' ' . $lastmod . '' . "\n";
+ }
+
+ $xml .= ' ' . $changefreq . '' . "\n";
+ $xml .= ' 0.8' . "\n";
+ $xml .= ' ' . "\n";
+ }
+
+ $xml .= '';
+
+ 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);
+ }
+}