diff --git a/src/packages/com_mokoog/forms/tag.xml b/src/packages/com_mokoog/forms/tag.xml
index 363ef5f..3c1ab5c 100644
--- a/src/packages/com_mokoog/forms/tag.xml
+++ b/src/packages/com_mokoog/forms/tag.xml
@@ -70,4 +70,37 @@
+
diff --git a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini b/src/packages/com_mokoog/language/en-GB/com_mokoog.ini
index 0e56059..c3f3d80 100644
--- a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini
+++ b/src/packages/com_mokoog/language/en-GB/com_mokoog.ini
@@ -13,4 +13,29 @@ COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type"
COM_MOKOOG_HEADING_CONTENT_ID="Content ID"
COM_MOKOOG_HEADING_OG_TITLE="OG Title"
COM_MOKOOG_HEADING_IMAGE="Image"
+COM_MOKOOG_HEADING_SEO="SEO"
COM_MOKOOG_HEADING_MODIFIED="Modified"
+
+COM_MOKOOG_SEO_OK="OK"
+COM_MOKOOG_SEO_MISSING_DESC="No meta description"
+COM_MOKOOG_SEO_TITLE_LONG="SEO title too long"
+COM_MOKOOG_SEO_NOINDEX="noindex"
+
+COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type"
+COM_MOKOOG_FIELD_CONTENT_ID="Content ID"
+COM_MOKOOG_FIELD_OG_TITLE="OG Title"
+COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing."
+COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
+COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing."
+COM_MOKOOG_FIELD_OG_IMAGE="OG Image"
+COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing."
+COM_MOKOOG_FIELD_OG_TYPE="OG Type"
+COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type."
+
+COM_MOKOOG_FILTER_SEARCH="Search OG titles"
+COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
+COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -"
+COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending"
+COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending"
+COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending"
+COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending"
diff --git a/src/packages/com_mokoog/language/en-US/com_mokoog.ini b/src/packages/com_mokoog/language/en-US/com_mokoog.ini
index 0e56059..c3f3d80 100644
--- a/src/packages/com_mokoog/language/en-US/com_mokoog.ini
+++ b/src/packages/com_mokoog/language/en-US/com_mokoog.ini
@@ -13,4 +13,29 @@ COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type"
COM_MOKOOG_HEADING_CONTENT_ID="Content ID"
COM_MOKOOG_HEADING_OG_TITLE="OG Title"
COM_MOKOOG_HEADING_IMAGE="Image"
+COM_MOKOOG_HEADING_SEO="SEO"
COM_MOKOOG_HEADING_MODIFIED="Modified"
+
+COM_MOKOOG_SEO_OK="OK"
+COM_MOKOOG_SEO_MISSING_DESC="No meta description"
+COM_MOKOOG_SEO_TITLE_LONG="SEO title too long"
+COM_MOKOOG_SEO_NOINDEX="noindex"
+
+COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type"
+COM_MOKOOG_FIELD_CONTENT_ID="Content ID"
+COM_MOKOOG_FIELD_OG_TITLE="OG Title"
+COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing."
+COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
+COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing."
+COM_MOKOOG_FIELD_OG_IMAGE="OG Image"
+COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing."
+COM_MOKOOG_FIELD_OG_TYPE="OG Type"
+COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type."
+
+COM_MOKOOG_FILTER_SEARCH="Search OG titles"
+COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
+COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -"
+COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending"
+COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending"
+COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending"
+COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending"
diff --git a/src/packages/com_mokoog/sql/install.mysql.sql b/src/packages/com_mokoog/sql/install.mysql.sql
index 884d369..306bc3e 100644
--- a/src/packages/com_mokoog/sql/install.mysql.sql
+++ b/src/packages/com_mokoog/sql/install.mysql.sql
@@ -12,6 +12,10 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` (
`og_description` TEXT NOT NULL,
`og_image` VARCHAR(512) NOT NULL DEFAULT '',
`og_type` VARCHAR(50) NOT NULL DEFAULT 'article',
+ `seo_title` VARCHAR(70) NOT NULL DEFAULT '',
+ `meta_description` VARCHAR(200) NOT NULL DEFAULT '',
+ `robots` VARCHAR(100) NOT NULL DEFAULT '',
+ `canonical_url` VARCHAR(512) NOT NULL DEFAULT '',
`published` TINYINT(1) NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
diff --git a/src/packages/com_mokoog/sql/updates/mysql/01.01.00.sql b/src/packages/com_mokoog/sql/updates/mysql/01.01.00.sql
new file mode 100644
index 0000000..4f4c433
--- /dev/null
+++ b/src/packages/com_mokoog/sql/updates/mysql/01.01.00.sql
@@ -0,0 +1,9 @@
+--
+-- MokoOpenGraph 01.01.00 — Add SEO meta management columns
+--
+
+ALTER TABLE `#__mokoog_tags`
+ ADD COLUMN `seo_title` VARCHAR(70) NOT NULL DEFAULT '' AFTER `og_type`,
+ ADD COLUMN `meta_description` VARCHAR(200) NOT NULL DEFAULT '' AFTER `seo_title`,
+ ADD COLUMN `robots` VARCHAR(100) NOT NULL DEFAULT '' AFTER `meta_description`,
+ ADD COLUMN `canonical_url` VARCHAR(512) NOT NULL DEFAULT '' AFTER `robots`;
diff --git a/src/packages/com_mokoog/tmpl/tags/default.php b/src/packages/com_mokoog/tmpl/tags/default.php
index cfec7f7..779746f 100644
--- a/src/packages/com_mokoog/tmpl/tags/default.php
+++ b/src/packages/com_mokoog/tmpl/tags/default.php
@@ -50,6 +50,9 @@ use Joomla\CMS\Router\Route;
|
+
+
+ |
|
@@ -83,6 +86,30 @@ use Joomla\CMS\Router\Route;
+
+ meta_description)) {
+ $seoIssues[] = Text::_('COM_MOKOOG_SEO_MISSING_DESC');
+ }
+
+ if (!empty($item->seo_title) && \strlen($item->seo_title) > 60) {
+ $seoIssues[] = Text::_('COM_MOKOOG_SEO_TITLE_LONG');
+ }
+
+ if (!empty($item->robots) && str_contains($item->robots, 'noindex')) {
+ $seoIssues[] = Text::_('COM_MOKOOG_SEO_NOINDEX');
+ }
+
+ if (empty($seoIssues)) : ?>
+
+
+
+
+
+
+ |
published ? Text::_('JPUBLISHED') : Text::_('JUNPUBLISHED'); ?>
|
diff --git a/src/packages/plg_content_mokoog/forms/mokoog.xml b/src/packages/plg_content_mokoog/forms/mokoog.xml
index 1e9f026..9e5632e 100644
--- a/src/packages/plg_content_mokoog/forms/mokoog.xml
+++ b/src/packages/plg_content_mokoog/forms/mokoog.xml
@@ -50,5 +50,48 @@
+
diff --git a/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini b/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini
index 6ea916c..167fe7e 100644
--- a/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini
+++ b/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini
@@ -13,3 +13,16 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image"
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image."
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
+
+PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
+PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
+
+PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title"
+PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom tag. 50-60 characters recommended. Leave blank to use the default page title."
+PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
+PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default."
+PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive"
+PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)."
+PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
+PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
+PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
diff --git a/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini b/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini
index 6ea916c..167fe7e 100644
--- a/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini
+++ b/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini
@@ -13,3 +13,16 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image"
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image."
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
+
+PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
+PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
+
+PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title"
+PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom tag. 50-60 characters recommended. Leave blank to use the default page title."
+PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
+PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default."
+PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive"
+PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)."
+PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
+PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
+PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
diff --git a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php b/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php
index 07c1161..3d70565 100644
--- a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php
+++ b/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php
@@ -176,7 +176,10 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
- ->select($db->quoteName(['og_title', 'og_description', 'og_image', 'og_type']))
+ ->select($db->quoteName([
+ 'og_title', 'og_description', 'og_image', 'og_type',
+ 'seo_title', 'meta_description', 'robots', 'canonical_url',
+ ]))
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
->where($db->quoteName('content_id') . ' = ' . $contentId);
@@ -209,15 +212,26 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$db->setQuery($query);
$existingId = $db->loadResult();
+ // Robots may come as array from multi-select, join with comma
+ $robots = $ogData['robots'] ?? '';
+
+ if (\is_array($robots)) {
+ $robots = implode(', ', array_filter($robots));
+ }
+
$record = (object) [
- 'content_type' => $contentType,
- 'content_id' => $contentId,
- 'og_title' => trim($ogData['og_title'] ?? ''),
- 'og_description' => trim($ogData['og_description'] ?? ''),
- 'og_image' => trim($ogData['og_image'] ?? ''),
- 'og_type' => trim($ogData['og_type'] ?? 'article'),
- 'published' => 1,
- 'modified' => Factory::getDate()->toSql(),
+ 'content_type' => $contentType,
+ 'content_id' => $contentId,
+ 'og_title' => trim($ogData['og_title'] ?? ''),
+ 'og_description' => trim($ogData['og_description'] ?? ''),
+ 'og_image' => trim($ogData['og_image'] ?? ''),
+ 'og_type' => trim($ogData['og_type'] ?? 'article'),
+ 'seo_title' => trim($ogData['seo_title'] ?? ''),
+ 'meta_description' => trim($ogData['meta_description'] ?? ''),
+ 'robots' => trim($robots),
+ 'canonical_url' => trim($ogData['canonical_url'] ?? ''),
+ 'published' => 1,
+ 'modified' => Factory::getDate()->toSql(),
];
if ($existingId) {
diff --git a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini b/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini
index 4983711..a0902bd 100644
--- a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini
+++ b/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini
@@ -23,3 +23,5 @@ PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML_DESC="Remove HTML tags from the auto-generated description."
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH="Description Length"
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH_DESC="Maximum character length for the auto-generated og:description."
+PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
+PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
diff --git a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini b/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini
index 4983711..a0902bd 100644
--- a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini
+++ b/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini
@@ -23,3 +23,5 @@ PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML_DESC="Remove HTML tags from the auto-generated description."
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH="Description Length"
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH_DESC="Maximum character length for the auto-generated og:description."
+PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
+PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
diff --git a/src/packages/plg_system_mokoog/mokoog.xml b/src/packages/plg_system_mokoog/mokoog.xml
index 50a2120..2517076 100644
--- a/src/packages/plg_system_mokoog/mokoog.xml
+++ b/src/packages/plg_system_mokoog/mokoog.xml
@@ -107,6 +107,17 @@
min="50"
max="300"
/>
+
+
+
+
diff --git a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php b/src/packages/plg_system_mokoog/src/Extension/MokoOG.php
index 818e263..e571cc4 100644
--- a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php
+++ b/src/packages/plg_system_mokoog/src/Extension/MokoOG.php
@@ -17,6 +17,7 @@ use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
+use Joomla\Plugin\System\MokoOG\Helper\ImageHelper;
final class MokoOG extends CMSPlugin implements SubscriberInterface
{
@@ -67,6 +68,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
// Try to load custom OG data from the database
$ogData = $this->loadOgData($option, $view, $id);
+ // --- SEO meta tags (set first, before OG) ---
+ $this->applySeoTags($doc, $ogData);
+
// Build tag values — custom overrides auto-generated
$title = $ogData->og_title ?: $doc->getTitle();
$description = $ogData->og_description ?: $this->buildDescription($doc);
@@ -111,6 +115,44 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
}
}
+ /**
+ * Apply SEO meta tags (title, description, robots, canonical) to the document.
+ *
+ * @param \Joomla\CMS\Document\HtmlDocument $doc The document
+ * @param object $ogData The loaded OG/SEO data
+ *
+ * @return void
+ */
+ private function applySeoTags($doc, object $ogData): void
+ {
+ // Custom SEO title overrides the page
+ if (!empty($ogData->seo_title)) {
+ $doc->setTitle($ogData->seo_title);
+ }
+
+ // Custom meta description
+ if (!empty($ogData->meta_description)) {
+ $doc->setDescription($ogData->meta_description);
+ }
+
+ // Robots directive
+ if (!empty($ogData->robots)) {
+ $doc->setMetaData('robots', $ogData->robots);
+ }
+
+ // Canonical URL
+ if (!empty($ogData->canonical_url)) {
+ // Remove any existing canonical link first
+ foreach ($doc->_links as $link => $attribs) {
+ if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') {
+ unset($doc->_links[$link]);
+ }
+ }
+
+ $doc->addHeadLink($ogData->canonical_url, 'canonical');
+ }
+ }
+
/**
* Load custom OG data from the database for the current page.
*
@@ -123,10 +165,14 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
private function loadOgData(string $option, string $view, int $id): object
{
$empty = (object) [
- 'og_title' => '',
- 'og_description' => '',
- 'og_image' => '',
- 'og_type' => '',
+ 'og_title' => '',
+ 'og_description' => '',
+ 'og_image' => '',
+ 'og_type' => '',
+ 'seo_title' => '',
+ 'meta_description' => '',
+ 'robots' => '',
+ 'canonical_url' => '',
];
if (!$id) {
@@ -243,7 +289,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
}
/**
- * Resolve a relative image path to a full URL.
+ * Resolve a relative image path to a full URL, resizing for OG if needed.
*
* @param string $image Image path
*
@@ -255,6 +301,11 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return $image;
}
+ // Auto-resize to OG recommended dimensions if enabled
+ if ($this->params->get('auto_resize', 1)) {
+ $image = ImageHelper::resize($image);
+ }
+
return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/');
}
}
diff --git a/src/packages/plg_system_mokoog/src/Helper/ImageHelper.php b/src/packages/plg_system_mokoog/src/Helper/ImageHelper.php
new file mode 100644
index 0000000..8364f18
--- /dev/null
+++ b/src/packages/plg_system_mokoog/src/Helper/ImageHelper.php
@@ -0,0 +1,222 @@
+
+ * @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\Filesystem\File;
+use Joomla\CMS\Filesystem\Folder;
+
+class ImageHelper
+{
+ /**
+ * Target width for OG images (Facebook recommended).
+ */
+ private const TARGET_WIDTH = 1200;
+
+ /**
+ * Target height for OG images (Facebook recommended).
+ */
+ private const TARGET_HEIGHT = 630;
+
+ /**
+ * JPEG quality for generated images.
+ */
+ private const JPEG_QUALITY = 85;
+
+ /**
+ * Output directory relative to JPATH_ROOT.
+ */
+ private const OUTPUT_DIR = 'images/mokoog/generated';
+
+ /**
+ * Resize an image to OG-optimized dimensions if needed.
+ *
+ * Returns the path to the resized image relative to JPATH_ROOT,
+ * or the original path if no resize was needed or possible.
+ *
+ * @param string $imagePath Image path relative to JPATH_ROOT
+ * @param int $targetWidth Target width (default 1200)
+ * @param int $targetHeight Target height (default 630)
+ * @param int $quality JPEG quality 1-100 (default 85)
+ *
+ * @return string Path to the output image (relative to JPATH_ROOT)
+ */
+ public static function resize(
+ string $imagePath,
+ int $targetWidth = self::TARGET_WIDTH,
+ int $targetHeight = self::TARGET_HEIGHT,
+ int $quality = self::JPEG_QUALITY
+ ): string {
+ // Resolve absolute path
+ $absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
+
+ if (!is_file($absPath)) {
+ return $imagePath;
+ }
+
+ $imageInfo = @getimagesize($absPath);
+
+ if (!$imageInfo) {
+ return $imagePath;
+ }
+
+ [$origWidth, $origHeight, $type] = $imageInfo;
+
+ // Skip if already at or below target size
+ if ($origWidth <= $targetWidth && $origHeight <= $targetHeight) {
+ return $imagePath;
+ }
+
+ // Ensure output directory exists
+ $outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
+
+ if (!is_dir($outputDir)) {
+ Folder::create($outputDir);
+ }
+
+ // Generate output filename based on source hash + dimensions
+ $hash = md5($imagePath . $targetWidth . $targetHeight);
+ $outputName = $hash . '.jpg';
+ $outputPath = $outputDir . '/' . $outputName;
+ $outputRel = self::OUTPUT_DIR . '/' . $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 = $targetWidth / $targetHeight;
+ $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($targetWidth, $targetHeight);
+
+ imagecopyresampled(
+ $output,
+ $source,
+ 0,
+ 0,
+ $cropX,
+ $cropY,
+ $targetWidth,
+ $targetHeight,
+ $cropWidth,
+ $cropHeight
+ );
+
+ // Save as JPEG
+ imagejpeg($output, $outputPath, $quality);
+
+ imagedestroy($source);
+ imagedestroy($output);
+
+ return $outputRel;
+ }
+
+ /**
+ * Remove a generated image file.
+ *
+ * @param string $generatedPath Path relative to JPATH_ROOT
+ *
+ * @return void
+ */
+ public static function cleanup(string $generatedPath): void
+ {
+ if (empty($generatedPath) || !str_starts_with($generatedPath, self::OUTPUT_DIR)) {
+ return;
+ }
+
+ $absPath = JPATH_ROOT . '/' . $generatedPath;
+
+ if (is_file($absPath)) {
+ File::delete($absPath);
+ }
+ }
+
+ /**
+ * Check if an image meets minimum OG size requirements.
+ *
+ * @param string $imagePath Image path relative to JPATH_ROOT
+ *
+ * @return array{valid: bool, width: int, height: int, message: string}
+ */
+ public static function validate(string $imagePath): array
+ {
+ $absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
+
+ if (!is_file($absPath)) {
+ return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'File not found'];
+ }
+
+ $imageInfo = @getimagesize($absPath);
+
+ if (!$imageInfo) {
+ return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'Not a valid image'];
+ }
+
+ [$width, $height] = $imageInfo;
+
+ // Facebook minimum: 200x200, recommended: 1200x630
+ // WhatsApp minimum: 300x200
+ if ($width < 200 || $height < 200) {
+ return [
+ 'valid' => false,
+ 'width' => $width,
+ 'height' => $height,
+ 'message' => "Image too small ({$width}x{$height}). Minimum: 200x200px.",
+ ];
+ }
+
+ return ['valid' => true, 'width' => $width, 'height' => $height, 'message' => 'OK'];
+ }
+
+ /**
+ * Load an image resource from a file.
+ *
+ * @param string $path Absolute file path
+ * @param int $type IMAGETYPE_* constant
+ *
+ * @return \GdImage|false
+ */
+ private static function loadImage(string $path, int $type)
+ {
+ return match ($type) {
+ IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
+ IMAGETYPE_PNG => @imagecreatefrompng($path),
+ IMAGETYPE_GIF => @imagecreatefromgif($path),
+ IMAGETYPE_WEBP => @imagecreatefromwebp($path),
+ default => false,
+ };
+ }
+}