feat(images): auto-resize OG images (#2) #17

Merged
jmiller merged 2 commits from feature/2-image-auto-resize into dev 2026-05-23 23:02:53 +00:00
15 changed files with 508 additions and 14 deletions
+33
View File
@@ -70,4 +70,37 @@
<option value="0">JUNPUBLISHED</option>
</field>
</fieldset>
<fieldset name="seo" label="SEO Meta Tags">
<field
name="seo_title"
type="text"
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
filter="string"
maxlength="70"
/>
<field
name="meta_description"
type="textarea"
label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
filter="string"
rows="3"
maxlength="200"
/>
<field
name="robots"
type="text"
label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
filter="string"
/>
<field
name="canonical_url"
type="url"
label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
filter="url"
/>
</fieldset>
</form>
@@ -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"
@@ -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"
@@ -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',
@@ -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`;
@@ -50,6 +50,9 @@ use Joomla\CMS\Router\Route;
<th scope="col" class="w-10">
<?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?>
</th>
<th scope="col" class="w-10">
<?php echo Text::_('COM_MOKOOG_HEADING_SEO'); ?>
</th>
<th scope="col" class="w-10">
<?php echo Text::_('JSTATUS'); ?>
</th>
@@ -83,6 +86,30 @@ use Joomla\CMS\Router\Route;
<span class="icon-minus-circle text-muted" aria-hidden="true"></span>
<?php endif; ?>
</td>
<td>
<?php
$seoIssues = [];
if (empty($item->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)) : ?>
<span class="badge bg-success"><?php echo Text::_('COM_MOKOOG_SEO_OK'); ?></span>
<?php else : ?>
<?php foreach ($seoIssues as $issue) : ?>
<span class="badge bg-warning text-dark"><?php echo $issue; ?></span>
<?php endforeach; ?>
<?php endif; ?>
</td>
<td>
<?php echo $item->published ? Text::_('JPUBLISHED') : Text::_('JUNPUBLISHED'); ?>
</td>
@@ -50,5 +50,48 @@
<option value="video.other">Video</option>
</field>
</fieldset>
<fieldset name="mokoog_seo" label="PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL"
description="PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC">
<field
name="seo_title"
type="text"
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
filter="string"
maxlength="70"
/>
<field
name="meta_description"
type="textarea"
label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
filter="string"
rows="3"
maxlength="200"
/>
<field
name="robots"
type="list"
label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
default=""
multiple="true"
>
<option value="">PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT</option>
<option value="noindex">noindex</option>
<option value="nofollow">nofollow</option>
<option value="nosnippet">nosnippet</option>
<option value="noarchive">noarchive</option>
<option value="noimageindex">noimageindex</option>
</field>
<field
name="canonical_url"
type="url"
label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
filter="url"
validate="url"
/>
</fieldset>
</fields>
</form>
@@ -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 <title> 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."
@@ -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 <title> 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."
@@ -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) {
@@ -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/."
@@ -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/."
+11
View File
@@ -107,6 +107,17 @@
min="50"
max="300"
/>
<field
name="auto_resize"
type="radio"
label="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE"
description="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
@@ -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 <title>
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, '/');
}
}
@@ -0,0 +1,222 @@
<?php
/**
* @package MokoOpenGraph
* @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\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,
};
}
}