Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6be1848762 | |||
| 717cfe9a0e | |||
| 3d5e7eec8a | |||
| fbbe74079d | |||
| 8dc1e8175a | |||
| 6f5cc4425e | |||
| 7ef1d79336 | |||
| 6fc6c704ba | |||
| d6624af876 | |||
| 5fc0fbfc07 | |||
| 935185b46f | |||
| 54bf5f7737 | |||
| 1f0b373ba3 | |||
| 6d1eb2d993 |
@@ -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,38 @@ 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"
|
||||
|
||||
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:"
|
||||
|
||||
@@ -13,4 +13,38 @@ 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"
|
||||
|
||||
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:"
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<filename>provider.php</filename>
|
||||
</files>
|
||||
<files folder="src">
|
||||
<folder>ContentType</folder>
|
||||
<folder>Controller</folder>
|
||||
<folder>Extension</folder>
|
||||
<folder>Model</folder>
|
||||
|
||||
@@ -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`;
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @subpackage com_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\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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @subpackage com_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\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() ?: '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @subpackage com_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\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 '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @subpackage com_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\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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoOpenGraph
|
||||
* @subpackage com_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\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 '';
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ 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::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete');
|
||||
ToolbarHelper::preferences('com_mokoog');
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Layout\LayoutHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/** @var \Joomla\Component\MokoOG\Administrator\View\Tags\HtmlView $this */
|
||||
|
||||
$token = Session::getFormToken();
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm">
|
||||
<div class="row">
|
||||
@@ -50,6 +52,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 +88,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>
|
||||
@@ -107,3 +136,85 @@ use Joomla\CMS\Router\Route;
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Batch Generation Progress -->
|
||||
<div id="mokoog-batch-panel" style="display:none;" class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h4><?php echo Text::_('COM_MOKOOG_BATCH_TITLE'); ?></h4>
|
||||
<div class="progress mb-2">
|
||||
<div id="mokoog-batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%">0%</div>
|
||||
</div>
|
||||
<p id="mokoog-batch-status"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Intercept the batch.generate toolbar button
|
||||
var origSubmitbutton = Joomla.submitbutton;
|
||||
Joomla.submitbutton = function(task) {
|
||||
if (task === 'batch.generate') {
|
||||
mokoogBatchGenerate();
|
||||
return;
|
||||
}
|
||||
if (origSubmitbutton) {
|
||||
origSubmitbutton(task);
|
||||
}
|
||||
};
|
||||
|
||||
function mokoogBatchGenerate() {
|
||||
var panel = document.getElementById('mokoog-batch-panel');
|
||||
var bar = document.getElementById('mokoog-batch-bar');
|
||||
var status = document.getElementById('mokoog-batch-status');
|
||||
var token = '<?php echo $token; ?>';
|
||||
var chunkSize = 50;
|
||||
|
||||
panel.style.display = 'block';
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COUNTING', true); ?>';
|
||||
|
||||
// Step 1: Count eligible articles
|
||||
fetch('index.php?option=com_mokoog&task=batch.count&format=json&' + token + '=1')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(resp) {
|
||||
var total = resp.data.total;
|
||||
if (total === 0) {
|
||||
bar.style.width = '100%';
|
||||
bar.textContent = '100%';
|
||||
bar.classList.remove('progress-bar-animated');
|
||||
bar.classList.add('bg-success');
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_NONE', true); ?>';
|
||||
return;
|
||||
}
|
||||
status.textContent = total + ' <?php echo Text::_('COM_MOKOOG_BATCH_FOUND', true); ?>';
|
||||
processChunk(0, total, chunkSize, token, bar, status);
|
||||
})
|
||||
.catch(function(err) {
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function processChunk(offset, total, chunkSize, token, bar, status) {
|
||||
fetch('index.php?option=com_mokoog&task=batch.process&format=json&offset=' + offset + '&limit=' + chunkSize + '&' + token + '=1')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(resp) {
|
||||
var processed = resp.data.processed;
|
||||
var pct = Math.min(100, Math.round((processed / total) * 100));
|
||||
bar.style.width = pct + '%';
|
||||
bar.textContent = pct + '%';
|
||||
status.textContent = processed + ' / ' + total + ' <?php echo Text::_('COM_MOKOOG_BATCH_PROCESSED', true); ?>';
|
||||
|
||||
if (processed < total) {
|
||||
processChunk(processed, total, chunkSize, token, bar, status);
|
||||
} else {
|
||||
bar.classList.remove('progress-bar-animated');
|
||||
bar.classList.add('bg-success');
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COMPLETE', true); ?> ' + total + ' articles.';
|
||||
setTimeout(function() { location.reload(); }, 2000);
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)) {
|
||||
@@ -81,7 +82,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 +108,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 +150,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 +184,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 +220,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,7 @@ 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/."
|
||||
|
||||
@@ -23,3 +23,7 @@ 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/."
|
||||
|
||||
@@ -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,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,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);
|
||||
@@ -109,6 +127,51 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +186,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) {
|
||||
@@ -153,6 +220,29 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OG data by menu item ID.
|
||||
*
|
||||
@@ -237,13 +327,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 +365,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+91
@@ -1,4 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<updates>
|
||||
<!-- Managed by CI/CD pipeline — do not edit manually -->
|
||||
|
||||
<update>
|
||||
<name>MokoOpenGraph</name>
|
||||
<description>MokoOpenGraph stable build.</description>
|
||||
<element>pkg_mokoog</element>
|
||||
<type>package</type>
|
||||
<version>01.00.00</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<infourl title='MokoOpenGraph'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/tag/v01</infourl>
|
||||
<downloads>
|
||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/download/v01/pkg_mokoog-01.00.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>2a1e5988c09ae57cecd43e52812aeee7f9e3674a16c5ae0142123b751f3a194f</sha256>
|
||||
<tags><tag>development</tag></tags>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name='joomla' version='(5|6).*'/>
|
||||
</update>
|
||||
|
||||
<update>
|
||||
<name>MokoOpenGraph</name>
|
||||
<description>MokoOpenGraph stable build.</description>
|
||||
<element>pkg_mokoog</element>
|
||||
<type>package</type>
|
||||
<version>01.00.00</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<infourl title='MokoOpenGraph'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/tag/v01</infourl>
|
||||
<downloads>
|
||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/download/v01/pkg_mokoog-01.00.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>2a1e5988c09ae57cecd43e52812aeee7f9e3674a16c5ae0142123b751f3a194f</sha256>
|
||||
<tags><tag>alpha</tag></tags>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name='joomla' version='(5|6).*'/>
|
||||
</update>
|
||||
|
||||
<update>
|
||||
<name>MokoOpenGraph</name>
|
||||
<description>MokoOpenGraph stable build.</description>
|
||||
<element>pkg_mokoog</element>
|
||||
<type>package</type>
|
||||
<version>01.00.00</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<infourl title='MokoOpenGraph'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/tag/v01</infourl>
|
||||
<downloads>
|
||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/download/v01/pkg_mokoog-01.00.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>2a1e5988c09ae57cecd43e52812aeee7f9e3674a16c5ae0142123b751f3a194f</sha256>
|
||||
<tags><tag>beta</tag></tags>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name='joomla' version='(5|6).*'/>
|
||||
</update>
|
||||
|
||||
<update>
|
||||
<name>MokoOpenGraph</name>
|
||||
<description>MokoOpenGraph stable build.</description>
|
||||
<element>pkg_mokoog</element>
|
||||
<type>package</type>
|
||||
<version>01.00.00</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<infourl title='MokoOpenGraph'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/tag/v01</infourl>
|
||||
<downloads>
|
||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/download/v01/pkg_mokoog-01.00.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>2a1e5988c09ae57cecd43e52812aeee7f9e3674a16c5ae0142123b751f3a194f</sha256>
|
||||
<tags><tag>rc</tag></tags>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name='joomla' version='(5|6).*'/>
|
||||
</update>
|
||||
|
||||
<update>
|
||||
<name>MokoOpenGraph</name>
|
||||
<description>MokoOpenGraph stable build.</description>
|
||||
<element>pkg_mokoog</element>
|
||||
<type>package</type>
|
||||
<version>01.00.00</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<infourl title='MokoOpenGraph'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/tag/v01</infourl>
|
||||
<downloads>
|
||||
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/download/v01/pkg_mokoog-01.00.00.zip</downloadurl>
|
||||
</downloads>
|
||||
<sha256>2a1e5988c09ae57cecd43e52812aeee7f9e3674a16c5ae0142123b751f3a194f</sha256>
|
||||
<tags><tag>stable</tag></tags>
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
<targetplatform name='joomla' version='(5|6).*'/>
|
||||
</update>
|
||||
|
||||
</updates>
|
||||
|
||||
Reference in New Issue
Block a user