diff --git a/src/packages/com_mokoog/api/src/Controller/TagsController.php b/src/packages/com_mokoog/api/src/Controller/TagsController.php new file mode 100644 index 0000000..28457f4 --- /dev/null +++ b/src/packages/com_mokoog/api/src/Controller/TagsController.php @@ -0,0 +1,68 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Api\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Controller\ApiController; + +class TagsController extends ApiController +{ + /** + * The content type for JSON:API output. + * + * @var string + */ + protected $contentType = 'tags'; + + /** + * The default view for the API. + * + * @var string + */ + protected $default_view = 'tags'; + + /** + * Lookup an OG tag by content_type and content_id. + * + * GET /api/index.php/v1/mokoog/lookup/:content_type/:content_id + * + * @return static + */ + public function lookup(): static + { + $contentType = $this->input->getString('content_type', ''); + $contentId = $this->input->getInt('content_id', 0); + + if (empty($contentType) || $contentId <= 0) { + throw new \RuntimeException('content_type and content_id are required', 400); + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType)) + ->where($db->quoteName('content_id') . ' = ' . $contentId); + + $db->setQuery($query); + $id = $db->loadResult(); + + if (!$id) { + throw new \RuntimeException('OG tag not found for ' . $contentType . ':' . $contentId, 404); + } + + $this->input->set('id', $id); + + return $this->displayItem(); + } +} diff --git a/src/packages/com_mokoog/api/src/View/Tags/JsonapiView.php b/src/packages/com_mokoog/api/src/View/Tags/JsonapiView.php new file mode 100644 index 0000000..c103df2 --- /dev/null +++ b/src/packages/com_mokoog/api/src/View/Tags/JsonapiView.php @@ -0,0 +1,66 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Api\View\Tags; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\JsonApiView as BaseApiView; + +class JsonapiView extends BaseApiView +{ + /** + * The fields to render in the API response. + * + * Whitelist of fields from #__mokoog_tags that are safe to expose. + * + * @var array + */ + protected $fieldsToRenderItem = [ + 'id', + 'content_type', + 'content_id', + 'og_title', + 'og_description', + 'og_image', + 'og_type', + 'seo_title', + 'meta_description', + 'robots', + 'canonical_url', + 'language', + 'published', + 'created', + 'modified', + ]; + + /** + * The fields to render in list responses. + * + * @var array + */ + protected $fieldsToRenderList = [ + 'id', + 'content_type', + 'content_id', + 'og_title', + 'og_description', + 'og_image', + 'og_type', + 'seo_title', + 'meta_description', + 'robots', + 'canonical_url', + 'language', + 'published', + 'created', + 'modified', + ]; +} 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..c7dca09 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,45 @@ 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_DEBUG="Debug" 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" + +COM_MOKOOG_TOOLBAR_BATCH_GENERATE="Batch Generate" +COM_MOKOOG_BATCH_TITLE="Batch OG Tag Generation" +COM_MOKOOG_BATCH_COUNTING="Counting articles without OG tags..." +COM_MOKOOG_BATCH_NONE="All articles already have OG tags." +COM_MOKOOG_BATCH_FOUND="articles found without OG tags." +COM_MOKOOG_BATCH_PROCESSED="processed" +COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!" +COM_MOKOOG_BATCH_ERROR="Error:" + +COM_MOKOOG_TOOLBAR_EXPORT="Export CSV" +COM_MOKOOG_TOOLBAR_IMPORT="Import CSV" +COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded." +COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file." +COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped." 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..c7dca09 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,45 @@ 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_DEBUG="Debug" 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" + +COM_MOKOOG_TOOLBAR_BATCH_GENERATE="Batch Generate" +COM_MOKOOG_BATCH_TITLE="Batch OG Tag Generation" +COM_MOKOOG_BATCH_COUNTING="Counting articles without OG tags..." +COM_MOKOOG_BATCH_NONE="All articles already have OG tags." +COM_MOKOOG_BATCH_FOUND="articles found without OG tags." +COM_MOKOOG_BATCH_PROCESSED="processed" +COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!" +COM_MOKOOG_BATCH_ERROR="Error:" + +COM_MOKOOG_TOOLBAR_EXPORT="Export CSV" +COM_MOKOOG_TOOLBAR_IMPORT="Import CSV" +COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded." +COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file." +COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped." diff --git a/src/packages/com_mokoog/mokoog.xml b/src/packages/com_mokoog/mokoog.xml index 3089e4f..f5baeb3 100644 --- a/src/packages/com_mokoog/mokoog.xml +++ b/src/packages/com_mokoog/mokoog.xml @@ -42,6 +42,7 @@ provider.php + ContentType Controller Extension Model @@ -68,4 +69,10 @@ COM_MOKOOG_SUBMENU_TAGS + + + + src + + diff --git a/src/packages/com_mokoog/sql/install.mysql.sql b/src/packages/com_mokoog/sql/install.mysql.sql index 884d369..cd91ceb 100644 --- a/src/packages/com_mokoog/sql/install.mysql.sql +++ b/src/packages/com_mokoog/sql/install.mysql.sql @@ -12,10 +12,15 @@ 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 '', + `language` CHAR(7) 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', PRIMARY KEY (`id`), - UNIQUE KEY `idx_content` (`content_type`, `content_id`), + UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`), KEY `idx_published` (`published`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 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/sql/updates/mysql/01.02.00.sql b/src/packages/com_mokoog/sql/updates/mysql/01.02.00.sql new file mode 100644 index 0000000..2655270 --- /dev/null +++ b/src/packages/com_mokoog/sql/updates/mysql/01.02.00.sql @@ -0,0 +1,10 @@ +-- +-- MokoOpenGraph 01.02.00 — Add multilingual OG tag support +-- + +ALTER TABLE `#__mokoog_tags` + ADD COLUMN `language` CHAR(7) NOT NULL DEFAULT '*' AFTER `canonical_url`; + +ALTER TABLE `#__mokoog_tags` + DROP INDEX `idx_content`, + ADD UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`); diff --git a/src/packages/com_mokoog/src/ContentType/ContentTypeInterface.php b/src/packages/com_mokoog/src/ContentType/ContentTypeInterface.php new file mode 100644 index 0000000..c596ca0 --- /dev/null +++ b/src/packages/com_mokoog/src/ContentType/ContentTypeInterface.php @@ -0,0 +1,60 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\ContentType; + +defined('_JEXEC') or die; + +interface ContentTypeInterface +{ + /** + * Check if this adapter can handle the given component/view. + * + * @param string $option Component option (e.g. com_virtuemart) + * @param string $view View name (e.g. productdetails) + * + * @return bool + */ + public function canHandle(string $option, string $view): bool; + + /** + * Get the content type identifier for database storage. + * + * @return string + */ + public function getContentType(): string; + + /** + * Get the title for the content item. + * + * @param int $id Content item ID + * + * @return string + */ + public function getTitle(int $id): string; + + /** + * Get a description for the content item. + * + * @param int $id Content item ID + * + * @return string + */ + public function getDescription(int $id): string; + + /** + * Get the primary image for the content item. + * + * @param int $id Content item ID + * + * @return string Image path relative to JPATH_ROOT, or empty string + */ + public function getImage(int $id): string; +} diff --git a/src/packages/com_mokoog/src/ContentType/HikaShopAdapter.php b/src/packages/com_mokoog/src/ContentType/HikaShopAdapter.php new file mode 100644 index 0000000..595f6e4 --- /dev/null +++ b/src/packages/com_mokoog/src/ContentType/HikaShopAdapter.php @@ -0,0 +1,76 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\ContentType; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class HikaShopAdapter implements ContentTypeInterface +{ + public function canHandle(string $option, string $view): bool + { + return $option === 'com_hikashop' && $view === 'product'; + } + + public function getContentType(): string + { + return 'com_hikashop'; + } + + public function getTitle(int $id): string + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('product_name')) + ->from($db->quoteName('#__hikashop_product')) + ->where($db->quoteName('product_id') . ' = ' . $id); + + $db->setQuery($query); + + return $db->loadResult() ?: ''; + } + + public function getDescription(int $id): string + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('product_description')) + ->from($db->quoteName('#__hikashop_product')) + ->where($db->quoteName('product_id') . ' = ' . $id); + + $db->setQuery($query); + $text = $db->loadResult() ?: ''; + $text = strip_tags($text); + $text = trim(preg_replace('/\s+/', ' ', $text)); + + if (\strlen($text) > 160) { + $text = mb_substr($text, 0, 157) . '...'; + } + + return $text; + } + + public function getImage(int $id): string + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('f.file_path')) + ->from($db->quoteName('#__hikashop_file', 'f')) + ->where($db->quoteName('f.file_ref_id') . ' = ' . $id) + ->where($db->quoteName('f.file_type') . ' = ' . $db->quote('product')) + ->order($db->quoteName('f.file_ordering') . ' ASC'); + + $db->setQuery($query, 0, 1); + + return $db->loadResult() ?: ''; + } +} diff --git a/src/packages/com_mokoog/src/ContentType/K2Adapter.php b/src/packages/com_mokoog/src/ContentType/K2Adapter.php new file mode 100644 index 0000000..f8dcd5d --- /dev/null +++ b/src/packages/com_mokoog/src/ContentType/K2Adapter.php @@ -0,0 +1,73 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\ContentType; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class K2Adapter implements ContentTypeInterface +{ + public function canHandle(string $option, string $view): bool + { + return $option === 'com_k2' && $view === 'item'; + } + + public function getContentType(): string + { + return 'com_k2'; + } + + public function getTitle(int $id): string + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('title')) + ->from($db->quoteName('#__k2_items')) + ->where($db->quoteName('id') . ' = ' . $id); + + $db->setQuery($query); + + return $db->loadResult() ?: ''; + } + + public function getDescription(int $id): string + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('introtext')) + ->from($db->quoteName('#__k2_items')) + ->where($db->quoteName('id') . ' = ' . $id); + + $db->setQuery($query); + $text = $db->loadResult() ?: ''; + $text = strip_tags($text); + $text = trim(preg_replace('/\s+/', ' ', $text)); + + if (\strlen($text) > 160) { + $text = mb_substr($text, 0, 157) . '...'; + } + + return $text; + } + + public function getImage(int $id): string + { + // K2 stores images as media/k2/items/cache/{md5}_L.jpg + $imagePath = 'media/k2/items/cache/' . md5('Image' . $id) . '_L.jpg'; + + if (is_file(JPATH_ROOT . '/' . $imagePath)) { + return $imagePath; + } + + return ''; + } +} diff --git a/src/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php b/src/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php new file mode 100644 index 0000000..60822ec --- /dev/null +++ b/src/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php @@ -0,0 +1,82 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\ContentType; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class VirtueMartAdapter implements ContentTypeInterface +{ + public function canHandle(string $option, string $view): bool + { + return $option === 'com_virtuemart' && $view === 'productdetails'; + } + + public function getContentType(): string + { + return 'com_virtuemart'; + } + + public function getTitle(int $id): string + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('product_name')) + ->from($db->quoteName('#__virtuemart_products_' . $this->getLangTag())) + ->where($db->quoteName('virtuemart_product_id') . ' = ' . $id); + + $db->setQuery($query); + + return $db->loadResult() ?: ''; + } + + public function getDescription(int $id): string + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('product_s_desc')) + ->from($db->quoteName('#__virtuemart_products_' . $this->getLangTag())) + ->where($db->quoteName('virtuemart_product_id') . ' = ' . $id); + + $db->setQuery($query); + $desc = $db->loadResult() ?: ''; + + return strip_tags($desc); + } + + public function getImage(int $id): string + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('m.file_url')) + ->from($db->quoteName('#__virtuemart_product_medias', 'pm')) + ->join('INNER', $db->quoteName('#__virtuemart_medias', 'm') . ' ON ' . $db->quoteName('m.virtuemart_media_id') . ' = ' . $db->quoteName('pm.virtuemart_media_id')) + ->where($db->quoteName('pm.virtuemart_product_id') . ' = ' . $id) + ->order($db->quoteName('pm.ordering') . ' ASC'); + + $db->setQuery($query, 0, 1); + + return $db->loadResult() ?: ''; + } + + /** + * Get the VirtueMart language table suffix. + * + * @return string + */ + private function getLangTag(): string + { + $lang = Factory::getLanguage()->getTag(); + + return strtolower(str_replace('-', '_', $lang)); + } +} diff --git a/src/packages/com_mokoog/src/Controller/BatchController.php b/src/packages/com_mokoog/src/Controller/BatchController.php new file mode 100644 index 0000000..909ef78 --- /dev/null +++ b/src/packages/com_mokoog/src/Controller/BatchController.php @@ -0,0 +1,169 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Response\JsonResponse; +use Joomla\CMS\Session\Session; + +class BatchController extends BaseController +{ + /** + * Count the total articles eligible for batch generation. + * + * @return void + */ + public function count(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__content', 'c')) + ->leftJoin( + $db->quoteName('#__mokoog_tags', 't') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->where($db->quoteName('c.state') . ' = 1') + ->where($db->quoteName('t.id') . ' IS NULL'); + + $db->setQuery($query); + $total = (int) $db->loadResult(); + + echo new JsonResponse(['total' => $total]); + + Factory::getApplication()->close(); + } + + /** + * Process a chunk of articles for batch OG generation. + * + * @return void + */ + public function process(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + $app = Factory::getApplication(); + $offset = $app->getInput()->getInt('offset', 0); + $limit = $app->getInput()->getInt('limit', 50); + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName([ + 'c.id', 'c.title', 'c.metadesc', 'c.introtext', 'c.fulltext', 'c.images', + ])) + ->from($db->quoteName('#__content', 'c')) + ->leftJoin( + $db->quoteName('#__mokoog_tags', 't') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->where($db->quoteName('c.state') . ' = 1') + ->where($db->quoteName('t.id') . ' IS NULL') + ->order($db->quoteName('c.id') . ' ASC'); + + $db->setQuery($query, $offset, $limit); + $articles = $db->loadObjectList(); + + $created = 0; + $now = Factory::getDate()->toSql(); + + foreach ($articles as $article) { + $ogTitle = $article->title; + $ogDescription = $this->extractDescription($article); + $ogImage = $this->extractImage($article); + + $record = (object) [ + 'content_type' => 'com_content', + 'content_id' => (int) $article->id, + 'og_title' => $ogTitle, + 'og_description' => $ogDescription, + 'og_image' => $ogImage, + 'og_type' => 'article', + 'seo_title' => '', + 'meta_description' => $article->metadesc ?: '', + 'robots' => '', + 'canonical_url' => '', + 'published' => 1, + 'created' => $now, + 'modified' => $now, + ]; + + $db->insertObject('#__mokoog_tags', $record); + $created++; + } + + echo new JsonResponse([ + 'created' => $created, + 'offset' => $offset, + 'processed' => $offset + $created, + ]); + + $app->close(); + } + + /** + * Extract a description from article content. + * + * @param object $article Article record + * + * @return string + */ + private function extractDescription(object $article): string + { + // Prefer meta description if set + if (!empty($article->metadesc)) { + return $article->metadesc; + } + + // Fall back to intro text + $text = $article->introtext ?: $article->fulltext; + $text = strip_tags($text); + $text = trim(preg_replace('/\s+/', ' ', $text)); + + if (\strlen($text) > 160) { + $text = mb_substr($text, 0, 157) . '...'; + } + + return $text; + } + + /** + * Extract the best image from article data. + * + * @param object $article Article record + * + * @return string + */ + private function extractImage(object $article): string + { + if (!empty($article->images)) { + $images = json_decode($article->images, true); + + if (!empty($images['image_fulltext'])) { + return $images['image_fulltext']; + } + + if (!empty($images['image_intro'])) { + return $images['image_intro']; + } + } + + return ''; + } +} diff --git a/src/packages/com_mokoog/src/Controller/ImportExportController.php b/src/packages/com_mokoog/src/Controller/ImportExportController.php new file mode 100644 index 0000000..aec4584 --- /dev/null +++ b/src/packages/com_mokoog/src/Controller/ImportExportController.php @@ -0,0 +1,188 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Session\Session; + +class ImportExportController extends BaseController +{ + /** + * Export all OG tags as CSV. + * + * @return void + */ + public function export(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + $app = Factory::getApplication(); + $db = Factory::getDbo(); + + // Join with #__content to get article titles for reference + $query = $db->getQuery(true) + ->select([ + $db->quoteName('t.content_type'), + $db->quoteName('t.content_id'), + 'COALESCE(' . $db->quoteName('c.title') . ', ' . $db->quote('') . ') AS ' . $db->quoteName('article_title'), + $db->quoteName('t.og_title'), + $db->quoteName('t.og_description'), + $db->quoteName('t.og_image'), + $db->quoteName('t.og_type'), + $db->quoteName('t.seo_title'), + $db->quoteName('t.meta_description'), + $db->quoteName('t.robots'), + $db->quoteName('t.canonical_url'), + ]) + ->from($db->quoteName('#__mokoog_tags', 't')) + ->leftJoin( + $db->quoteName('#__content', 'c') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->order($db->quoteName('t.content_type') . ', ' . $db->quoteName('t.content_id')); + + $db->setQuery($query); + $rows = $db->loadAssocList(); + + // Send CSV headers + $app->setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="mokoog_tags_export.csv"'); + $app->sendHeaders(); + + $output = fopen('php://output', 'w'); + + // Header row + fputcsv($output, [ + 'content_type', 'content_id', 'article_title', + 'og_title', 'og_description', 'og_image', 'og_type', + 'seo_title', 'meta_description', 'robots', 'canonical_url', + ]); + + foreach ($rows as $row) { + fputcsv($output, $row); + } + + fclose($output); + $app->close(); + } + + /** + * Import OG tags from uploaded CSV. + * + * @return void + */ + public function import(): void + { + Session::checkToken() || jexit(Text::_('JINVALID_TOKEN')); + + $app = Factory::getApplication(); + $input = $app->getInput(); + $files = $input->files->get('jform', [], 'array'); + + if (empty($files['csv_file']['tmp_name'])) { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_NO_FILE'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + $tmpFile = $files['csv_file']['tmp_name']; + $handle = fopen($tmpFile, 'r'); + + if (!$handle) { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_READ_ERROR'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + $db = Factory::getDbo(); + $header = fgetcsv($handle); + $created = 0; + $updated = 0; + $skipped = 0; + $now = Factory::getDate()->toSql(); + + while (($row = fgetcsv($handle)) !== false) { + if (\count($row) < 7) { + $skipped++; + + continue; + } + + $contentType = trim($row[0]); + $contentId = (int) $row[1]; + // $row[2] = article_title (informational, skip) + $ogTitle = trim($row[3] ?? ''); + $ogDescription = trim($row[4] ?? ''); + $ogImage = trim($row[5] ?? ''); + $ogType = trim($row[6] ?? 'article'); + $seoTitle = trim($row[7] ?? ''); + $metaDesc = trim($row[8] ?? ''); + $robots = trim($row[9] ?? ''); + $canonicalUrl = trim($row[10] ?? ''); + + if (empty($contentType) || $contentId <= 0) { + $skipped++; + + continue; + } + + // Check for existing record + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType)) + ->where($db->quoteName('content_id') . ' = ' . $contentId); + + $db->setQuery($query); + $existingId = $db->loadResult(); + + $record = (object) [ + 'content_type' => $contentType, + 'content_id' => $contentId, + 'og_title' => $ogTitle, + 'og_description' => $ogDescription, + 'og_image' => $ogImage, + 'og_type' => $ogType, + 'seo_title' => $seoTitle, + 'meta_description' => $metaDesc, + 'robots' => $robots, + 'canonical_url' => $canonicalUrl, + 'published' => 1, + 'modified' => $now, + ]; + + if ($existingId) { + $record->id = $existingId; + $db->updateObject('#__mokoog_tags', $record, 'id'); + $updated++; + } else { + $record->created = $now; + $db->insertObject('#__mokoog_tags', $record); + $created++; + } + } + + fclose($handle); + + $app->enqueueMessage( + Text::sprintf('COM_MOKOOG_IMPORT_RESULT', $created, $updated, $skipped), + 'success' + ); + $app->redirect('index.php?option=com_mokoog&view=tags'); + } +} diff --git a/src/packages/com_mokoog/src/Model/TagModel.php b/src/packages/com_mokoog/src/Model/TagModel.php new file mode 100644 index 0000000..62d14b9 --- /dev/null +++ b/src/packages/com_mokoog/src/Model/TagModel.php @@ -0,0 +1,68 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\AdminModel; + +class TagModel extends AdminModel +{ + /** + * Get the form for the item. + * + * @param array $data Form data + * @param bool $loadData Load data from state + * + * @return \Joomla\CMS\Form\Form|false + */ + public function getForm($data = [], $loadData = true) + { + $form = $this->loadForm( + 'com_mokoog.tag', + 'tag', + ['control' => 'jform', 'load_data' => $loadData] + ); + + return $form ?: false; + } + + /** + * Load the form data. + * + * @return object + */ + protected function loadFormData(): object + { + $data = Factory::getApplication()->getUserState('com_mokoog.edit.tag.data', []); + + if (empty($data)) { + $data = $this->getItem(); + } + + return $data; + } + + /** + * Get the table class name. + * + * @param string $name Table name + * @param string $prefix Table prefix + * @param array $options Table options + * + * @return \Joomla\CMS\Table\Table + */ + public function getTable($name = 'Tag', $prefix = 'Administrator', $options = []) + { + return parent::getTable($name, $prefix, $options); + } +} diff --git a/src/packages/com_mokoog/src/View/Tags/HtmlView.php b/src/packages/com_mokoog/src/View/Tags/HtmlView.php index 14ff7cb..cf93aff 100644 --- a/src/packages/com_mokoog/src/View/Tags/HtmlView.php +++ b/src/packages/com_mokoog/src/View/Tags/HtmlView.php @@ -39,6 +39,20 @@ class HtmlView extends BaseHtmlView */ protected $state; + /** + * The filter form. + * + * @var \Joomla\CMS\Form\Form|null + */ + public $filterForm; + + /** + * The active filters. + * + * @var array + */ + public $activeFilters = []; + /** * Display the view. * @@ -48,9 +62,11 @@ class HtmlView extends BaseHtmlView */ public function display($tpl = null): void { - $this->items = $this->get('Items'); - $this->pagination = $this->get('Pagination'); - $this->state = $this->get('State'); + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); $this->addToolbar(); @@ -65,6 +81,8 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { ToolbarHelper::title(Text::_('COM_MOKOOG_TAGS_TITLE'), 'bookmark'); + ToolbarHelper::custom('batch.generate', 'refresh', '', 'COM_MOKOOG_TOOLBAR_BATCH_GENERATE', false); + ToolbarHelper::custom('importexport.export', 'download', '', 'COM_MOKOOG_TOOLBAR_EXPORT', false); ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete'); ToolbarHelper::preferences('com_mokoog'); } diff --git a/src/packages/com_mokoog/tmpl/tags/default.php b/src/packages/com_mokoog/tmpl/tags/default.php index cfec7f7..094de26 100644 --- a/src/packages/com_mokoog/tmpl/tags/default.php +++ b/src/packages/com_mokoog/tmpl/tags/default.php @@ -14,14 +14,18 @@ use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\Layout\LayoutHelper; use Joomla\CMS\Router\Route; +use Joomla\CMS\Uri\Uri; +use Joomla\CMS\Session\Session; /** @var \Joomla\Component\MokoOG\Administrator\View\Tags\HtmlView $this */ +$token = Session::getFormToken(); ?>
+ $this]); ?> items)) : ?>
@@ -50,9 +54,15 @@ use Joomla\CMS\Router\Route; + + + + + + @@ -83,9 +93,50 @@ 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'); ?> + + content_type === 'com_content') { + $debugUrl = Uri::root() . 'index.php?option=com_content&view=article&id=' . (int) $item->content_id; + } elseif ($item->content_type === 'menu') { + $debugUrl = Uri::root() . 'index.php?Itemid=' . (int) $item->content_id; + } elseif ($item->content_type === 'com_content.category') { + $debugUrl = Uri::root() . 'index.php?option=com_content&view=category&id=' . (int) $item->content_id; + } else { + $debugUrl = Uri::root(); + } + ?> + FB + LI + G + modified, Text::_('DATE_FORMAT_LC4')); ?> @@ -107,3 +158,85 @@ use Joomla\CMS\Router\Route;
+ + + + + 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 <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." diff --git a/src/packages/plg_content_mokoog/media/css/preview.css b/src/packages/plg_content_mokoog/media/css/preview.css new file mode 100644 index 0000000..b5d74a9 --- /dev/null +++ b/src/packages/plg_content_mokoog/media/css/preview.css @@ -0,0 +1,104 @@ +/** + * @package MokoOpenGraph + * @subpackage plg_content_mokoog + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GPL-3.0-or-later + */ + +.mokoog-preview-wrapper { + margin: 15px 0; + padding: 15px; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #dee2e6; +} + +.mokoog-preview-heading { + margin: 0 0 12px; + font-size: 14px; + color: #666; +} + +.mokoog-platform-label { + display: block; + color: #999; + text-transform: uppercase; + font-size: 11px; + font-weight: 600; + margin-top: 16px; + margin-bottom: 4px; +} + +.mokoog-platform-label:first-of-type { + margin-top: 0; +} + +.mokoog-card { + overflow: hidden; + max-width: 500px; + background: #fff; +} + +.mokoog-card-fb { + border: 1px solid #ddd; + border-radius: 3px; +} + +.mokoog-card-tw { + border: 1px solid #cfd9de; + border-radius: 16px; +} + +.mokoog-card-img { + height: 260px; + background: #e4e6eb center / cover no-repeat; +} + +.mokoog-card-body { + padding: 10px 12px; + border-top: 1px solid #ddd; +} + +.mokoog-card-tw .mokoog-card-body { + border-top-color: #cfd9de; +} + +.mokoog-card-domain { + font-size: 11px; + color: #65676b; + text-transform: uppercase; +} + +.mokoog-card-tw .mokoog-card-domain { + font-size: 13px; + text-transform: none; + margin-top: 4px; +} + +.mokoog-card-title { + font-size: 16px; + font-weight: 600; + color: #1d2129; + margin: 3px 0 2px; + line-height: 1.3; +} + +.mokoog-card-tw .mokoog-card-title { + font-size: 15px; + font-weight: 700; + color: #0f1419; +} + +.mokoog-card-desc { + font-size: 14px; + color: #65676b; + line-height: 1.4; + max-height: 2.8em; + overflow: hidden; +} + +.mokoog-card-tw .mokoog-card-desc { + font-size: 15px; + color: #536471; + margin-top: 2px; +} diff --git a/src/packages/plg_content_mokoog/media/joomla.asset.json b/src/packages/plg_content_mokoog/media/joomla.asset.json new file mode 100644 index 0000000..340aa7a --- /dev/null +++ b/src/packages/plg_content_mokoog/media/joomla.asset.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json", + "name": "plg_content_mokoog", + "version": "01.00.00", + "description": "MokoOpenGraph Content Plugin Assets", + "license": "GPL-3.0-or-later", + "assets": [ + { + "name": "plg_content_mokoog.preview", + "type": "style", + "uri": "plg_content_mokoog/css/preview.css" + }, + { + "name": "plg_content_mokoog.preview", + "type": "script", + "uri": "plg_content_mokoog/js/preview.js", + "dependencies": ["core"] + } + ] +} diff --git a/src/packages/plg_content_mokoog/media/js/preview.js b/src/packages/plg_content_mokoog/media/js/preview.js new file mode 100644 index 0000000..62e8925 --- /dev/null +++ b/src/packages/plg_content_mokoog/media/js/preview.js @@ -0,0 +1,170 @@ +/** + * @package MokoOpenGraph + * @subpackage plg_content_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GPL-3.0-or-later + * + * Live social sharing preview for article/menu item editor. + */ +document.addEventListener('DOMContentLoaded', function () { + 'use strict'; + + var fields = { + ogTitle: document.getElementById('jform_mokoog_og_title'), + ogDesc: document.getElementById('jform_mokoog_og_description'), + ogImage: document.getElementById('jform_mokoog_og_image'), + articleTitle: document.getElementById('jform_title'), + metaDesc: document.getElementById('jform_metadesc') + }; + + // Find the mokoog fieldset and insert preview after it + var fieldset = document.querySelector('[data-showon-id="mokoog"]') || + document.getElementById('attrib-mokoog') || + document.querySelector('fieldset.mokoog') || + document.querySelector('[id*="mokoog"]'); + + if (!fieldset) { + return; + } + + // Build preview DOM safely (no innerHTML with user data) + var preview = document.createElement('div'); + preview.id = 'mokoog-preview'; + + var wrapper = document.createElement('div'); + wrapper.className = 'mokoog-preview-wrapper'; + + var heading = document.createElement('h4'); + heading.className = 'mokoog-preview-heading'; + heading.textContent = 'Social Sharing Preview'; + wrapper.appendChild(heading); + + // Facebook preview card + var fbLabel = document.createElement('small'); + fbLabel.className = 'mokoog-platform-label'; + fbLabel.textContent = 'Facebook'; + wrapper.appendChild(fbLabel); + + var fbCard = document.createElement('div'); + fbCard.className = 'mokoog-card mokoog-card-fb'; + + var fbImg = document.createElement('div'); + fbImg.id = 'mokoog-fb-img'; + fbImg.className = 'mokoog-card-img'; + fbCard.appendChild(fbImg); + + var fbBody = document.createElement('div'); + fbBody.className = 'mokoog-card-body'; + + var fbDomain = document.createElement('div'); + fbDomain.id = 'mokoog-fb-domain'; + fbDomain.className = 'mokoog-card-domain'; + fbBody.appendChild(fbDomain); + + var fbTitle = document.createElement('div'); + fbTitle.id = 'mokoog-fb-title'; + fbTitle.className = 'mokoog-card-title'; + fbBody.appendChild(fbTitle); + + var fbDesc = document.createElement('div'); + fbDesc.id = 'mokoog-fb-desc'; + fbDesc.className = 'mokoog-card-desc'; + fbBody.appendChild(fbDesc); + + fbCard.appendChild(fbBody); + wrapper.appendChild(fbCard); + + // Twitter preview card + var twLabel = document.createElement('small'); + twLabel.className = 'mokoog-platform-label'; + twLabel.textContent = 'Twitter / X'; + wrapper.appendChild(twLabel); + + var twCard = document.createElement('div'); + twCard.className = 'mokoog-card mokoog-card-tw'; + + var twImg = document.createElement('div'); + twImg.id = 'mokoog-tw-img'; + twImg.className = 'mokoog-card-img'; + twCard.appendChild(twImg); + + var twBody = document.createElement('div'); + twBody.className = 'mokoog-card-body'; + + var twTitle = document.createElement('div'); + twTitle.id = 'mokoog-tw-title'; + twTitle.className = 'mokoog-card-title'; + twBody.appendChild(twTitle); + + var twDesc = document.createElement('div'); + twDesc.id = 'mokoog-tw-desc'; + twDesc.className = 'mokoog-card-desc'; + twBody.appendChild(twDesc); + + var twDomain = document.createElement('div'); + twDomain.id = 'mokoog-tw-domain'; + twDomain.className = 'mokoog-card-domain'; + twBody.appendChild(twDomain); + + twCard.appendChild(twBody); + wrapper.appendChild(twCard); + + preview.appendChild(wrapper); + fieldset.parentNode.insertBefore(preview, fieldset.nextSibling); + + var domain = window.location.hostname; + + function updatePreview() { + var title = (fields.ogTitle && fields.ogTitle.value) || + (fields.articleTitle && fields.articleTitle.value) || 'Page Title'; + var desc = (fields.ogDesc && fields.ogDesc.value) || + (fields.metaDesc && fields.metaDesc.value) || 'Page description will appear here...'; + var img = ''; + + if (fields.ogImage) { + img = fields.ogImage.value; + } + + if (title.length > 65) title = title.substring(0, 62) + '...'; + if (desc.length > 160) desc = desc.substring(0, 157) + '...'; + + // Facebook + document.getElementById('mokoog-fb-title').textContent = title; + document.getElementById('mokoog-fb-desc').textContent = desc; + document.getElementById('mokoog-fb-domain').textContent = domain; + var fbImgEl = document.getElementById('mokoog-fb-img'); + if (img) { + fbImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')'; + fbImgEl.style.display = ''; + } else { + fbImgEl.style.display = 'none'; + } + + // Twitter + document.getElementById('mokoog-tw-title').textContent = title; + document.getElementById('mokoog-tw-desc').textContent = desc; + document.getElementById('mokoog-tw-domain').textContent = domain; + var twImgEl = document.getElementById('mokoog-tw-img'); + if (img) { + twImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')'; + twImgEl.style.display = ''; + } else { + twImgEl.style.display = 'none'; + } + } + + Object.values(fields).forEach(function (el) { + if (el) { + el.addEventListener('input', updatePreview); + el.addEventListener('change', updatePreview); + } + }); + + if (fields.ogImage) { + var observer = new MutationObserver(updatePreview); + observer.observe(fields.ogImage, { attributes: true, attributeFilter: ['value'] }); + } + + updatePreview(); +}); diff --git a/src/packages/plg_content_mokoog/mokoog.xml b/src/packages/plg_content_mokoog/mokoog.xml index 651e9ba..bb64e27 100644 --- a/src/packages/plg_content_mokoog/mokoog.xml +++ b/src/packages/plg_content_mokoog/mokoog.xml @@ -27,6 +27,12 @@ <folder>language</folder> </files> + <media destination="plg_content_mokoog" folder="media"> + <filename>joomla.asset.json</filename> + <folder>js</folder> + <folder>css</folder> + </media> + <languages> <language tag="en-GB">language/en-GB/plg_content_mokoog.ini</language> <language tag="en-GB">language/en-GB/plg_content_mokoog.sys.ini</language> diff --git a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php b/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php index 07c1161..d89afb4 100644 --- a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php +++ b/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php @@ -56,10 +56,11 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface $formName = $form->getName(); - // Add OG fields to article and menu item edit forms + // Add OG fields to article, menu item, and category edit forms $supportedForms = [ 'com_content.article', 'com_menus.item', + 'com_categories.categorycom_content', ]; if (!\in_array($formName, $supportedForms, true)) { @@ -71,6 +72,12 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface Form::addFormPath($formPath); $form->loadFile('mokoog', false); + // Load live preview assets + $wa = $this->getApplication()->getDocument()->getWebAssetManager(); + $wa->getRegistry()->addRegistryFile('media/plg_content_mokoog/joomla.asset.json'); + $wa->useStyle('plg_content_mokoog.preview'); + $wa->useScript('plg_content_mokoog.preview'); + // If editing an existing item, load saved OG data $id = 0; @@ -81,7 +88,12 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface } if ($id > 0) { - $contentType = ($formName === 'com_menus.item') ? 'menu' : 'com_content'; + $formTypeMap = [ + 'com_content.article' => 'com_content', + 'com_menus.item' => 'menu', + 'com_categories.categorycom_content' => 'com_content.category', + ]; + $contentType = $formTypeMap[$formName] ?? 'com_content'; $ogData = $this->loadOgData($contentType, $id); if ($ogData) { @@ -102,8 +114,9 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface [$context, $article, $isNew] = array_values($event->getArguments()); $supportedContexts = [ - 'com_content.article' => 'com_content', - 'com_menus.item' => 'menu', + 'com_content.article' => 'com_content', + 'com_menus.item' => 'menu', + 'com_categories.categorycom_content' => 'com_content.category', ]; if (!isset($supportedContexts[$context])) { @@ -143,8 +156,9 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface [$context, $article] = array_values($event->getArguments()); $supportedContexts = [ - 'com_content.article' => 'com_content', - 'com_menus.item' => 'menu', + 'com_content.article' => 'com_content', + 'com_menus.item' => 'menu', + 'com_categories.categorycom_content' => 'com_content.category', ]; if (!isset($supportedContexts[$context])) { @@ -176,7 +190,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 +226,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..987bbc1 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,11 @@ 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_TELEGRAM_CHANNEL="Telegram Channel" +PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL_DESC="Your Telegram channel handle (e.g. @mokoconsulting). Outputs a telegram:channel meta tag for Telegram link previews." +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/." +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD" +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results." +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs" +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway." 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..987bbc1 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,11 @@ 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_TELEGRAM_CHANNEL="Telegram Channel" +PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL_DESC="Your Telegram channel handle (e.g. @mokoconsulting). Outputs a telegram:channel meta tag for Telegram link previews." +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/." +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD" +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results." +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs" +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway." diff --git a/src/packages/plg_system_mokoog/mokoog.xml b/src/packages/plg_system_mokoog/mokoog.xml index 50a2120..c1a12ac 100644 --- a/src/packages/plg_system_mokoog/mokoog.xml +++ b/src/packages/plg_system_mokoog/mokoog.xml @@ -74,6 +74,14 @@ default="" filter="string" /> + <field + name="telegram_channel" + type="text" + label="PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL" + description="PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL_DESC" + default="" + filter="string" + /> </fieldset> <fieldset name="advanced" label="PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED"> <field @@ -107,6 +115,39 @@ 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> + <field + name="jsonld_enabled" + type="radio" + label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED" + description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC" + default="1" + class="btn-group" + > + <option value="1">JYES</option> + <option value="0">JNO</option> + </field> + <field + name="jsonld_breadcrumbs" + type="radio" + label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS" + description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC" + default="1" + class="btn-group" + > + <option value="1">JYES</option> + <option value="0">JNO</option> + </field> </fieldset> </fields> </config> diff --git a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php b/src/packages/plg_system_mokoog/src/Extension/MokoOG.php index 818e263..756fbdd 100644 --- a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php +++ b/src/packages/plg_system_mokoog/src/Extension/MokoOG.php @@ -17,6 +17,8 @@ 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; +use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder; final class MokoOG extends CMSPlugin implements SubscriberInterface { @@ -67,6 +69,23 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface // Try to load custom OG data from the database $ogData = $this->loadOgData($option, $view, $id); + // For category views, also try category-level OG data as fallback + if ($option === 'com_content' && $view === 'category' && $id > 0) { + $catOg = $this->loadOgDataByType('com_content.category', $id); + + if ($catOg) { + // Merge: category fills any gaps in the content-level data + foreach (['og_title', 'og_description', 'og_image', 'og_type', 'seo_title', 'meta_description', 'robots', 'canonical_url'] as $field) { + if (empty($ogData->$field) && !empty($catOg->$field)) { + $ogData->$field = $catOg->$field; + } + } + } + } + + // --- 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); @@ -87,6 +106,11 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface $doc->setMetaData('og:image', $imageUrl, 'property'); } + // og:locale from current language + $langTag = Factory::getLanguage()->getTag(); + $ogLocale = str_replace('-', '_', $langTag); + $doc->setMetaData('og:locale', $ogLocale, 'property'); + // Facebook App ID $fbAppId = $this->params->get('fb_app_id', ''); @@ -109,6 +133,74 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface if ($twitterSite) { $doc->setMetaData('twitter:site', $twitterSite); } + + // Telegram channel tag + $telegramChannel = $this->params->get('telegram_channel', ''); + + if ($telegramChannel) { + $doc->setMetaData('telegram:channel', $telegramChannel); + } + + // JSON-LD structured data + if ($this->params->get('jsonld_enabled', 1)) { + $imageUrl = $image ? $this->resolveImageUrl($image) : ''; + + if ($option === 'com_content' && $view === 'article' && $id > 0) { + $schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl); + } else { + $schema = JsonLdBuilder::buildWebPage($title, $description); + } + + if ($schema) { + $doc->addCustomTag(JsonLdBuilder::toScriptTag($schema)); + } + + if ($this->params->get('jsonld_breadcrumbs', 1)) { + $breadcrumbs = JsonLdBuilder::buildBreadcrumbs(); + + if ($breadcrumbs) { + $doc->addCustomTag(JsonLdBuilder::toScriptTag($breadcrumbs)); + } + } + } + } + + /** + * 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'); + } } /** @@ -123,10 +215,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) { @@ -146,11 +242,37 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface ->from($db->quoteName('#__mokoog_tags')) ->where($db->quoteName('content_type') . ' = ' . $db->quote($option)) ->where($db->quoteName('content_id') . ' = ' . (int) $id) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('language') . ' = ' . $db->quote(Factory::getLanguage()->getTag()) + . ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')') + ->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC'); + + $db->setQuery($query, 0, 1); + + return $db->loadObject() ?: $empty; + } + + /** + * Load OG data by content type and ID. + * + * @param string $contentType Content type identifier + * @param int $contentId Content ID + * + * @return object|null + */ + private function loadOgDataByType(string $contentType, int $contentId): ?object + { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType)) + ->where($db->quoteName('content_id') . ' = ' . $contentId) ->where($db->quoteName('published') . ' = 1'); $db->setQuery($query); - return $db->loadObject() ?: $empty; + return $db->loadObject(); } /** @@ -237,13 +359,33 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface return $imagesData['image_intro']; } } + + // Fallback: check the article's category for an image + if ($view === 'article') { + $catQuery = $db->getQuery(true) + ->select($db->quoteName('cat.params')) + ->from($db->quoteName('#__categories', 'cat')) + ->join('INNER', $db->quoteName('#__content', 'a') . ' ON ' . $db->quoteName('a.catid') . ' = ' . $db->quoteName('cat.id')) + ->where($db->quoteName('a.id') . ' = ' . (int) $id); + + $db->setQuery($catQuery); + $catParams = $db->loadResult(); + + if ($catParams) { + $catData = json_decode($catParams, true); + + if (!empty($catData['image'])) { + return $catData['image']; + } + } + } } return $this->params->get('default_image', ''); } /** - * 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 +397,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/ImageGenerator.php b/src/packages/plg_system_mokoog/src/Helper/ImageGenerator.php new file mode 100644 index 0000000..2837cdc --- /dev/null +++ b/src/packages/plg_system_mokoog/src/Helper/ImageGenerator.php @@ -0,0 +1,160 @@ +<?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\Folder; + +class ImageGenerator +{ + private const WIDTH = 1200; + private const HEIGHT = 630; + private const OUTPUT_DIR = 'images/mokoog/generated'; + + /** + * Generate an OG image with title text overlaid on a template background. + * + * @param string $title Article title to overlay + * @param string $templateImage Path to template/background image relative to JPATH_ROOT + * @param string $fontFile Absolute path to TTF font file + * @param int $fontSize Font size in points (default 42) + * @param array $fontColor RGB array [r, g, b] (default white) + * @param int $quality JPEG quality (default 90) + * + * @return string Path to generated image relative to JPATH_ROOT, or empty on failure + */ + public static function generate( + string $title, + string $templateImage, + string $fontFile = '', + int $fontSize = 42, + array $fontColor = [255, 255, 255], + int $quality = 90 + ): string { + $templateAbs = JPATH_ROOT . '/' . ltrim($templateImage, '/'); + + if (!is_file($templateAbs)) { + return ''; + } + + if (!$fontFile || !is_file($fontFile)) { + return ''; + } + + $outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR; + + if (!is_dir($outputDir)) { + Folder::create($outputDir); + } + + $hash = md5($title . $templateImage . $fontSize); + $outputName = 'overlay_' . $hash . '.jpg'; + $outputPath = $outputDir . '/' . $outputName; + $outputRel = self::OUTPUT_DIR . '/' . $outputName; + + // Skip if already generated + if (is_file($outputPath)) { + return $outputRel; + } + + // Load template image + $imageInfo = @getimagesize($templateAbs); + + if (!$imageInfo) { + return ''; + } + + $source = match ($imageInfo[2]) { + IMAGETYPE_JPEG => @imagecreatefromjpeg($templateAbs), + IMAGETYPE_PNG => @imagecreatefrompng($templateAbs), + IMAGETYPE_WEBP => @imagecreatefromwebp($templateAbs), + default => false, + }; + + if (!$source) { + return ''; + } + + // Create output canvas at target dimensions + $canvas = imagecreatetruecolor(self::WIDTH, self::HEIGHT); + + imagecopyresampled( + $canvas, + $source, + 0, 0, 0, 0, + self::WIDTH, self::HEIGHT, + $imageInfo[0], $imageInfo[1] + ); + + imagedestroy($source); + + // Semi-transparent overlay for text readability + $overlay = imagecolorallocatealpha($canvas, 0, 0, 0, 64); + imagefilledrectangle($canvas, 0, (int) (self::HEIGHT * 0.55), self::WIDTH, self::HEIGHT, $overlay); + + // Render title text with word wrapping + $textColor = imagecolorallocate($canvas, $fontColor[0], $fontColor[1], $fontColor[2]); + $wrappedTitle = self::wrapText($title, $fontFile, $fontSize, (int) (self::WIDTH * 0.85)); + $textX = (int) (self::WIDTH * 0.075); + $textY = (int) (self::HEIGHT * 0.72); + + imagettftext($canvas, $fontSize, 0, $textX, $textY, $textColor, $fontFile, $wrappedTitle); + + // Save + imagejpeg($canvas, $outputPath, $quality); + imagedestroy($canvas); + + return $outputRel; + } + + /** + * Wrap text to fit within a maximum pixel width. + * + * @param string $text Text to wrap + * @param string $fontFile Path to TTF font + * @param int $fontSize Font size in points + * @param int $maxWidth Maximum width in pixels + * + * @return string Wrapped text with newlines + */ + private static function wrapText(string $text, string $fontFile, int $fontSize, int $maxWidth): string + { + $words = explode(' ', $text); + $lines = []; + $line = ''; + + foreach ($words as $word) { + $testLine = $line ? $line . ' ' . $word : $word; + $bbox = imagettfbbox($fontSize, 0, $fontFile, $testLine); + $lineWidth = abs($bbox[4] - $bbox[0]); + + if ($lineWidth > $maxWidth && $line !== '') { + $lines[] = $line; + $line = $word; + } else { + $line = $testLine; + } + } + + if ($line !== '') { + $lines[] = $line; + } + + // Limit to 3 lines, truncate last line if needed + if (\count($lines) > 3) { + $lines = \array_slice($lines, 0, 3); + $lines[2] = mb_substr($lines[2], 0, -3) . '...'; + } + + return implode("\n", $lines); + } +} 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 @@ +<?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, + }; + } +} diff --git a/src/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php b/src/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php new file mode 100644 index 0000000..d56f029 --- /dev/null +++ b/src/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php @@ -0,0 +1,168 @@ +<?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\Factory; +use Joomla\CMS\Uri\Uri; + +class JsonLdBuilder +{ + /** + * Build Article schema for a com_content article. + * + * @param int $articleId Article ID + * @param string $title Page title + * @param string $description Page description + * @param string $image Image URL (absolute) + * + * @return array|null + */ + public static function buildArticle(int $articleId, string $title, string $description, string $image): ?array + { + if ($articleId <= 0) { + return null; + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName([ + 'a.created', 'a.modified', 'a.publish_up', + 'u.name', + ])) + ->from($db->quoteName('#__content', 'a')) + ->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by')) + ->where($db->quoteName('a.id') . ' = ' . $articleId); + + $db->setQuery($query); + $article = $db->loadObject(); + + if (!$article) { + return null; + } + + $schema = [ + '@context' => 'https://schema.org', + '@type' => 'Article', + 'headline' => $title, + 'description' => $description, + 'url' => Uri::getInstance()->toString(), + 'datePublished' => $article->publish_up ?: $article->created, + 'dateModified' => $article->modified ?: $article->created, + ]; + + if (!empty($article->name)) { + $schema['author'] = [ + '@type' => 'Person', + 'name' => $article->name, + ]; + } + + if (!empty($image)) { + $schema['image'] = $image; + } + + return $schema; + } + + /** + * Build WebPage schema for non-article pages. + * + * @param string $title Page title + * @param string $description Page description + * + * @return array + */ + public static function buildWebPage(string $title, string $description): array + { + return [ + '@context' => 'https://schema.org', + '@type' => 'WebPage', + 'name' => $title, + 'description' => $description, + 'url' => Uri::getInstance()->toString(), + ]; + } + + /** + * Build BreadcrumbList schema from Joomla's pathway. + * + * @return array|null + */ + public static function buildBreadcrumbs(): ?array + { + $app = Factory::getApplication(); + $pathway = $app->getPathway(); + $items = $pathway->getPathway(); + + if (empty($items)) { + return null; + } + + $listItems = []; + $position = 1; + + foreach ($items as $item) { + $url = $item->link; + + if ($url && !str_starts_with($url, 'http')) { + $url = rtrim(Uri::root(), '/') . '/' . ltrim($url, '/'); + } + + $listItems[] = [ + '@type' => 'ListItem', + 'position' => $position, + 'name' => $item->name, + 'item' => $url ?: Uri::getInstance()->toString(), + ]; + + $position++; + } + + return [ + '@context' => 'https://schema.org', + '@type' => 'BreadcrumbList', + 'itemListElement' => $listItems, + ]; + } + + /** + * Build Organization schema from site configuration. + * + * @param string $siteName Site name + * + * @return array + */ + public static function buildOrganization(string $siteName): array + { + return [ + '@context' => 'https://schema.org', + '@type' => 'Organization', + 'name' => $siteName, + 'url' => Uri::root(), + ]; + } + + /** + * Encode a schema array to a JSON-LD script tag string. + * + * @param array $schema Schema data + * + * @return string + */ + public static function toScriptTag(array $schema): string + { + $json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + + return '<script type="application/ld+json">' . $json . '</script>'; + } +} diff --git a/src/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini b/src/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini new file mode 100644 index 0000000..e397923 --- /dev/null +++ b/src/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini @@ -0,0 +1,5 @@ +; MokoOpenGraph - Web Services Plugin Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_WEBSERVICES_MOKOOG="Web Services - MokoOpenGraph" diff --git a/src/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini b/src/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini new file mode 100644 index 0000000..903c909 --- /dev/null +++ b/src/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini @@ -0,0 +1,6 @@ +; MokoOpenGraph - Web Services Plugin System Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_WEBSERVICES_MOKOOG="Web Services - MokoOpenGraph" +PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags." diff --git a/src/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini b/src/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini new file mode 100644 index 0000000..e397923 --- /dev/null +++ b/src/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini @@ -0,0 +1,5 @@ +; MokoOpenGraph - Web Services Plugin Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_WEBSERVICES_MOKOOG="Web Services - MokoOpenGraph" diff --git a/src/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini b/src/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini new file mode 100644 index 0000000..903c909 --- /dev/null +++ b/src/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini @@ -0,0 +1,6 @@ +; MokoOpenGraph - Web Services Plugin System Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_WEBSERVICES_MOKOOG="Web Services - MokoOpenGraph" +PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags." diff --git a/src/packages/plg_webservices_mokoog/mokoog.php b/src/packages/plg_webservices_mokoog/mokoog.php new file mode 100644 index 0000000..899de00 --- /dev/null +++ b/src/packages/plg_webservices_mokoog/mokoog.php @@ -0,0 +1,19 @@ +<?php + +/** + * @package MokoOpenGraph + * @subpackage plg_webservices_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 + * + * Legacy entry point — not executed under DI container. + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; + +class PlgWebservicesMokoog extends CMSPlugin +{ +} diff --git a/src/packages/plg_webservices_mokoog/mokoog.xml b/src/packages/plg_webservices_mokoog/mokoog.xml new file mode 100644 index 0000000..5f862ab --- /dev/null +++ b/src/packages/plg_webservices_mokoog/mokoog.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + * @package MokoOpenGraph + * @subpackage plg_webservices_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 + --> +<extension type="plugin" group="webservices" method="upgrade"> + <name>Web Services - MokoOpenGraph</name> + <version>01.00.00</version> + <creationDate>2026-05-23</creationDate> + <author>Moko Consulting</author> + <authorEmail>hello@mokoconsulting.tech</authorEmail> + <authorUrl>https://mokoconsulting.tech</authorUrl> + <copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright> + <license>GPL-3.0-or-later</license> + <description>PLG_WEBSERVICES_MOKOOG_DESCRIPTION</description> + + <namespace path="src">Joomla\Plugin\WebServices\MokoOG</namespace> + + <files> + <filename plugin="mokoog">mokoog.php</filename> + <folder>src</folder> + <folder>services</folder> + <folder>language</folder> + </files> + + <languages> + <language tag="en-GB">language/en-GB/plg_webservices_mokoog.ini</language> + <language tag="en-GB">language/en-GB/plg_webservices_mokoog.sys.ini</language> + </languages> +</extension> diff --git a/src/packages/plg_webservices_mokoog/services/provider.php b/src/packages/plg_webservices_mokoog/services/provider.php new file mode 100644 index 0000000..2aa760d --- /dev/null +++ b/src/packages/plg_webservices_mokoog/services/provider.php @@ -0,0 +1,44 @@ +<?php + +/** + * @package MokoOpenGraph + * @subpackage plg_webservices_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 + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\WebServices\MokoOG\Extension\MokoOGWebServices; + +return new class () implements ServiceProviderInterface { + /** + * Register the service provider. + * + * @param Container $container The DI container + * + * @return void + */ + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoOGWebServices( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('webservices', 'mokoog') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php b/src/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php new file mode 100644 index 0000000..887e3b8 --- /dev/null +++ b/src/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php @@ -0,0 +1,81 @@ +<?php + +/** + * @package MokoOpenGraph + * @subpackage plg_webservices_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\WebServices\MokoOG\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Router\ApiRouter; +use Joomla\Event\Event; +use Joomla\Event\SubscriberInterface; +use Joomla\Router\Route; + +final class MokoOGWebServices extends CMSPlugin implements SubscriberInterface +{ + /** + * @var bool + */ + protected $autoloadLanguage = true; + + /** + * Returns the events this plugin subscribes to. + * + * @return array<string, string> + */ + public static function getSubscribedEvents(): array + { + return [ + 'onBeforeApiRoute' => 'onBeforeApiRoute', + ]; + } + + /** + * Register API routes for MokoOpenGraph. + * + * Endpoints: + * GET /api/index.php/v1/mokoog/tags - List all OG tags + * GET /api/index.php/v1/mokoog/tags/:id - Get single OG tag + * POST /api/index.php/v1/mokoog/tags - Create OG tag + * PATCH /api/index.php/v1/mokoog/tags/:id - Update OG tag + * DELETE /api/index.php/v1/mokoog/tags/:id - Delete OG tag + * + * @param Event $event The event object + * + * @return void + */ + public function onBeforeApiRoute(Event $event): void + { + [$router] = array_values($event->getArguments()); + + $defaults = [ + 'component' => 'com_mokoog', + 'public' => false, + ]; + + // CRUD routes for OG tags + $router->createCRUDRoutes( + 'v1/mokoog/tags', + 'tags', + $defaults + ); + + // GET by content type + content ID (lookup endpoint) + $router->addRoute( + new Route( + ['GET'], + 'v1/mokoog/lookup/:content_type/:content_id', + 'tags.lookup', + ['content_type' => '[a-z_.]+', 'content_id' => '(\d+)'], + $defaults + ) + ); + } +} diff --git a/src/pkg_mokoog.xml b/src/pkg_mokoog.xml index 355f556..783c779 100644 --- a/src/pkg_mokoog.xml +++ b/src/pkg_mokoog.xml @@ -23,6 +23,7 @@ <file type="component" id="com_mokoog">com_mokoog.zip</file> <file type="plugin" id="mokoog" group="system">plg_system_mokoog.zip</file> <file type="plugin" id="mokoog" group="content">plg_content_mokoog.zip</file> + <file type="plugin" id="mokoog" group="webservices">plg_webservices_mokoog.zip</file> </files> <languages> diff --git a/src/script.php b/src/script.php index c5a505f..3f76f33 100644 --- a/src/script.php +++ b/src/script.php @@ -73,6 +73,28 @@ class Pkg_MokoOGInstallerScript $db->setQuery($query); $db->execute(); + + // Enable the content plugin automatically + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('content')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokoog')); + + $db->setQuery($query); + $db->execute(); + + // Enable the webservices plugin automatically + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('webservices')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokoog')); + + $db->setQuery($query); + $db->execute(); } } }