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); + } +}