* @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\Extension; defined('_JEXEC') or die; use Joomla\CMS\Factory; 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 { /** * @var bool */ protected $autoloadLanguage = true; /** * Returns the events this plugin subscribes to. * * @return array */ public static function getSubscribedEvents(): array { return [ 'onAfterRoute' => 'onAfterRoute', 'onBeforeCompileHead' => 'onBeforeCompileHead', ]; } /** * Run admin-side license key check after routing. * * @param Event $event The event object * * @return void */ public function onAfterRoute(Event $event): void { $app = $this->getApplication(); if ($app->isClient('administrator')) { $this->warnMissingLicenseKey(); } } /** * Inject Open Graph and Twitter Card meta tags before the document head is compiled. * * @param Event $event The event object * * @return void */ public function onBeforeCompileHead(Event $event): void { $app = $this->getApplication(); // Only run on the site frontend if (!$app->isClient('site')) { return; } $doc = $app->getDocument(); if ($doc->getType() !== 'html') { return; } $input = $app->getInput(); $option = $input->getCmd('option', ''); $view = $input->getCmd('view', ''); $id = $input->getInt('id', 0); // 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 OG data → site-wide defaults → auto-generated $defaultTitle = $this->params->get('default_og_title', ''); $defaultDesc = $this->params->get('default_og_description', ''); $title = $ogData->og_title ?: ($doc->getTitle() ?: $defaultTitle); $description = $ogData->og_description ?: ($this->buildDescription($doc) ?: $defaultDesc); $image = $ogData->og_image ?: $this->findImage($option, $view, $id); $url = Uri::getInstance()->toString(); $siteName = $this->params->get('og_site_name', $app->get('sitename', '')); $type = $ogData->og_type ?: 'article'; // Open Graph tags $doc->setMetaData('og:title', $title, 'property'); $doc->setMetaData('og:description', $description, 'property'); $doc->setMetaData('og:url', $url, 'property'); $doc->setMetaData('og:type', $type, 'property'); $doc->setMetaData('og:site_name', $siteName, 'property'); if ($image) { $imageUrl = $this->resolveImageUrl($image); $doc->setMetaData('og:image', $imageUrl, 'property'); // Emit actual image dimensions when detectable $imageDims = $this->getImageDimensions($image); if ($imageDims) { $doc->setMetaData('og:image:width', (string) $imageDims[0], 'property'); $doc->setMetaData('og:image:height', (string) $imageDims[1], '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', ''); if ($fbAppId) { $doc->setMetaData('fb:app_id', $fbAppId, 'property'); } // Twitter Card tags $cardType = $this->params->get('twitter_card_type', 'summary_large_image'); $twitterSite = $this->params->get('twitter_site', ''); $doc->setMetaData('twitter:card', $cardType); $doc->setMetaData('twitter:title', $title); $doc->setMetaData('twitter:description', $description); if ($image) { $doc->setMetaData('twitter:image', $this->resolveImageUrl($image)); } if ($twitterSite) { $doc->setMetaData('twitter:site', $twitterSite); } // Telegram channel tag $telegramChannel = $this->params->get('telegram_channel', ''); if ($telegramChannel) { $doc->setMetaData('telegram:channel', $telegramChannel); } // Discord embed color (theme-color meta tag) $discordColor = $this->params->get('discord_color', ''); if ($discordColor) { $doc->setMetaData('theme-color', $discordColor); } // LinkedIn article tags if ($option === 'com_content' && $view === 'article' && $id > 0) { $doc->setMetaData('article:published_time', $this->getArticleDate($id, 'publish_up'), 'property'); $doc->setMetaData('article:modified_time', $this->getArticleDate($id, 'modified'), 'property'); $author = $this->getArticleAuthor($id); if ($author) { $doc->setMetaData('article:author', $author, 'property'); } } // Fire event so third-party plugins can add custom OG/social tags $eventData = [ 'subject' => $doc, 'title' => $title, 'description' => $description, 'image' => $image ? $this->resolveImageUrl($image) : '', 'url' => $url, 'type' => $type, 'option' => $option, 'view' => $view, 'id' => $id, ]; $app->getDispatcher()->dispatch('onMokoOGAfterRender', new Event('onMokoOGAfterRender', $eventData)); // 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 if (!empty($ogData->seo_title)) { $doc->setTitle($ogData->seo_title); } // Custom meta description if (!empty($ogData->meta_description)) { $doc->setDescription($ogData->meta_description); } // Robots directive if (!empty($ogData->robots)) { $doc->setMetaData('robots', $ogData->robots); } // Canonical URL if (!empty($ogData->canonical_url)) { // Remove any existing canonical link first foreach ($doc->_links as $link => $attribs) { if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') { unset($doc->_links[$link]); } } $doc->addHeadLink($ogData->canonical_url, 'canonical'); } } /** * Load custom OG data from the database for the current page. * * @param string $option Component option * @param string $view View name * @param int $id Content ID * * @return object */ private function loadOgData(string $option, string $view, int $id): object { $empty = (object) [ 'og_title' => '', 'og_description' => '', 'og_image' => '', 'og_type' => '', 'seo_title' => '', 'meta_description' => '', 'robots' => '', 'canonical_url' => '', ]; if (!$id) { // Try menu-item-based lookup $menuItem = $this->getApplication()->getMenu()->getActive(); if (!$menuItem) { return $empty; } return $this->loadOgDataByMenu((int) $menuItem->id) ?: $empty; } $db = Factory::getDbo(); $query = $db->getQuery(true) ->select('*') ->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(); } /** * Load OG data by menu item ID. * * @param int $menuId Menu item ID * * @return object|null */ private function loadOgDataByMenu(int $menuId): ?object { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokoog_tags')) ->where($db->quoteName('content_type') . ' = ' . $db->quote('menu')) ->where($db->quoteName('content_id') . ' = ' . $menuId) ->where($db->quoteName('published') . ' = 1'); $db->setQuery($query); return $db->loadObject(); } /** * Build a description from the document metadata or page content. * * @param \Joomla\CMS\Document\HtmlDocument $doc The document * * @return string */ private function buildDescription($doc): string { $description = $doc->getDescription(); $maxLength = (int) $this->params->get('desc_length', 160); if ($this->params->get('strip_html', 1)) { $description = strip_tags($description); } $description = trim(preg_replace('/\s+/', ' ', $description)); if (mb_strlen($description) > $maxLength) { $description = mb_substr($description, 0, $maxLength - 3) . '...'; } return $description; } /** * Attempt to find the first image for the given content. * * @param string $option Component option * @param string $view View name * @param int $id Content ID * * @return string */ private function findImage(string $option, string $view, int $id): string { if (!$this->params->get('auto_generate', 1)) { return $this->params->get('default_image', ''); } // For Joomla articles, look at the intro/full image fields if ($option === 'com_content' && $id > 0) { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select($db->quoteName('images')) ->from($db->quoteName('#__content')) ->where($db->quoteName('id') . ' = ' . (int) $id); $db->setQuery($query); $images = $db->loadResult(); if ($images) { $imagesData = json_decode($images, true); if (!empty($imagesData['image_fulltext'])) { return $imagesData['image_fulltext']; } if (!empty($imagesData['image_intro'])) { 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, resizing for OG if needed. * * @param string $image Image path * * @return string */ private function resolveImageUrl(string $image): string { if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) { 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, '/'); } /** * Get a date field from an article. * * @param int $id Article ID * @param string $field Date field name (publish_up, modified, created) * * @return string ISO 8601 date string */ private function getArticleDate(int $id, string $field): string { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select($db->quoteName($field)) ->from($db->quoteName('#__content')) ->where($db->quoteName('id') . ' = ' . $id); $db->setQuery($query); $date = $db->loadResult(); return $date ?: ''; } /** * Get the author name for an article. * * @param int $id Article ID * * @return string */ private function getArticleAuthor(int $id): string { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select($db->quoteName('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') . ' = ' . $id); $db->setQuery($query); return $db->loadResult() ?: ''; } /** * Warn administrators once per session when no license key is configured. * * @return void */ private function warnMissingLicenseKey(): void { $session = Factory::getSession(); if ($session->get('mokoog.license_warned', false)) { return; } $user = Factory::getUser(); if ($user->guest || !$user->authorise('core.manage')) { return; } try { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select($db->quoteName('extra_query')) ->from($db->quoteName('#__update_sites')) ->where($db->quoteName('name') . ' = ' . $db->quote('MokoJoomOpenGraph Updates')) ->setLimit(1); $db->setQuery($query); $extraQuery = (string) $db->loadResult(); // Mark as checked only after the DB query succeeds $session->set('mokoog.license_warned', true); if (!empty($extraQuery)) { parse_str($extraQuery, $parsed); if (!empty($parsed['dlid']) && preg_match('/^MOKO-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $parsed['dlid'])) { return; } } $this->getApplication()->enqueueMessage( '<strong>Moko Consulting License Key Required</strong> — ' . 'No download key is configured. Updates will not be available until a valid license key is entered. ' . 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> ' . 'and enter your license key (<code>MOKO-XXXX-XXXX-XXXX-XXXX</code>) in the Download Key field ' . 'for the MokoJoomOpenGraph update site.', 'warning' ); } catch (\Throwable $e) { // Don't break admin over a license check } } /** * Get the actual pixel dimensions of a local image. * * Returns [width, height] or null for external URLs or unreadable images. * * @param string $image Image path (relative or absolute URL) * * @return array{0: int, 1: int}|null */ private function getImageDimensions(string $image): ?array { // Cannot determine dimensions for external URLs if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) { return null; } // If auto-resize is on, the resized image lives in the generated dir if ($this->params->get('auto_resize', 1)) { $resolved = ImageHelper::resize($image); } else { $resolved = $image; } $absPath = JPATH_ROOT . '/' . ltrim($resolved, '/'); if (!is_file($absPath)) { return null; } $info = @getimagesize($absPath); if (!$info) { return null; } return [$info[0], $info[1]]; } }