2026-05-23 17:10:17 -05:00
<? php
/**
2026-06-21 10:02:03 -05:00
* @package MokoSuiteOpenGraph
2026-05-23 17:10:17 -05:00
* @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\Extension ;
defined ( '_JEXEC' ) or die ;
use Joomla\CMS\Factory ;
use Joomla\CMS\Plugin\CMSPlugin ;
use Joomla\CMS\Uri\Uri ;
2026-05-23 17:40:40 -05:00
use Joomla\Event\Event ;
2026-05-23 17:10:17 -05:00
use Joomla\Event\SubscriberInterface ;
2026-05-23 17:57:07 -05:00
use Joomla\Plugin\System\MokoOG\Helper\ImageHelper ;
2026-05-23 18:23:07 -05:00
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder ;
2026-06-23 12:53:47 -05:00
use Joomla\Plugin\System\MokoOG\Helper\SitemapBuilder ;
2026-05-23 17:10:17 -05:00
final class MokoOG 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 [
2026-06-06 07:12:40 -05:00
'onAfterRoute' => 'onAfterRoute' ,
2026-05-23 17:10:17 -05:00
'onBeforeCompileHead' => 'onBeforeCompileHead' ,
2026-06-23 12:53:47 -05:00
'onContentAfterSave' => 'onContentAfterSaveRebuildSitemap' ,
'onAjaxMokoog' => 'onAjaxMokoog' ,
2026-05-23 17:10:17 -05:00
];
}
2026-06-06 07:12:40 -05:00
/**
* 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 ();
}
}
2026-05-23 17:10:17 -05:00
/**
* Inject Open Graph and Twitter Card meta tags before the document head is compiled.
*
2026-05-23 17:40:40 -05:00
* @param Event $event The event object
*
2026-05-23 17:10:17 -05:00
* @return void
*/
2026-05-23 17:40:40 -05:00
public function onBeforeCompileHead ( Event $event ) : void
2026-05-23 17:10:17 -05:00
{
$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 );
2026-05-23 18:10:53 -05:00
// 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
2026-06-23 10:25:21 -05:00
foreach ([ 'og_title' , 'og_description' , 'og_image' , 'og_type' , 'og_video' , 'seo_title' , 'meta_description' , 'robots' , 'canonical_url' ] as $field ) {
2026-05-23 18:10:53 -05:00
if ( empty ( $ogData -> $field ) && ! empty ( $catOg -> $field )) {
$ogData -> $field = $catOg -> $field ;
}
}
}
}
2026-05-23 17:50:28 -05:00
// --- SEO meta tags (set first, before OG) ---
$this -> applySeoTags ( $doc , $ogData );
2026-05-30 20:37:50 -05:00
// 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 );
2026-05-23 17:10:17 -05:00
$image = $ogData -> og_image ?: $this -> findImage ( $option , $view , $id );
$url = Uri :: getInstance () -> toString ();
$siteName = $this -> params -> get ( 'og_site_name' , $app -> get ( 'sitename' , '' ));
2026-06-21 10:20:38 -05:00
$defaultType = ( $option === 'com_mokoshop' && $view === 'product' ) ? 'product' : 'article' ;
$type = $ogData -> og_type ?: $defaultType ;
2026-05-23 17:10:17 -05:00
// 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' );
2026-05-30 20:37:50 -05:00
2026-06-06 07:12:40 -05:00
// 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' );
}
2026-05-23 17:10:17 -05:00
}
2026-05-23 18:35:49 -05:00
// og:locale from current language
2026-06-29 09:52:51 -05:00
$langTag = $this -> getApplication () -> getLanguage () -> getTag ();
2026-05-23 18:35:49 -05:00
$ogLocale = str_replace ( '-' , '_' , $langTag );
$doc -> setMetaData ( 'og:locale' , $ogLocale , 'property' );
2026-05-23 17:10:17 -05:00
// 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 ) {
2026-06-23 12:53:47 -05:00
$twitterImage = ( $this -> params -> get ( 'auto_resize' , 1 ) && $this -> params -> get ( 'platform_resize' , 0 ))
? ImageHelper :: resizeForPlatform ( $image , 'twitter' )
: $image ;
$doc -> setMetaData ( 'twitter:image' , $this -> resolveImageUrl ( $twitterImage ));
2026-05-23 17:10:17 -05:00
}
if ( $twitterSite ) {
$doc -> setMetaData ( 'twitter:site' , $twitterSite );
}
2026-05-23 18:12:38 -05:00
// Telegram channel tag
$telegramChannel = $this -> params -> get ( 'telegram_channel' , '' );
if ( $telegramChannel ) {
$doc -> setMetaData ( 'telegram:channel' , $telegramChannel );
}
2026-05-23 22:07:43 -05:00
2026-05-30 20:37:50 -05:00
// Discord embed color (theme-color meta tag)
$discordColor = $this -> params -> get ( 'discord_color' , '' );
if ( $discordColor ) {
$doc -> setMetaData ( 'theme-color' , $discordColor );
}
2026-06-23 09:07:51 -05:00
// Fediverse/Mastodon creator attribution
$fediverseCreator = $this -> params -> get ( 'fediverse_creator' , '' );
if ( $fediverseCreator ) {
$doc -> setMetaData ( 'fediverse:creator' , $fediverseCreator );
}
2026-06-23 10:25:21 -05:00
// og:video tags
$videoUrl = $ogData -> og_video ?? '' ;
if ( $videoUrl ) {
$doc -> setMetaData ( 'og:video' , $videoUrl , 'property' );
2026-06-23 10:45:30 -05:00
if ( str_starts_with ( $videoUrl , 'https://' )) {
$doc -> setMetaData ( 'og:video:secure_url' , $videoUrl , 'property' );
}
// Detect video type from URL — embeds vs direct files
$isEmbed = str_contains ( $videoUrl , 'youtube.com' ) || str_contains ( $videoUrl , 'youtu.be' )
|| str_contains ( $videoUrl , 'vimeo.com' );
if ( $isEmbed ) {
2026-06-23 10:25:21 -05:00
$doc -> setMetaData ( 'og:video:type' , 'text/html' , 'property' );
} else {
$ext = strtolower ( pathinfo ( parse_url ( $videoUrl , PHP_URL_PATH ) ?: '' , PATHINFO_EXTENSION ));
$mimeMap = [ 'mp4' => 'video/mp4' , 'webm' => 'video/webm' , 'ogg' => 'video/ogg' ];
$doc -> setMetaData ( 'og:video:type' , $mimeMap [ $ext ] ?? 'video/mp4' , 'property' );
2026-06-23 10:45:30 -05:00
$doc -> setMetaData ( 'og:video:width' , '1280' , 'property' );
$doc -> setMetaData ( 'og:video:height' , '720' , 'property' );
2026-06-23 10:25:21 -05:00
}
}
2026-05-30 20:37:50 -05:00
// 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' );
}
}
2026-06-23 10:45:30 -05:00
// MokoSuiteShop product meta tags (pricing + Pinterest availability)
2026-06-21 10:20:38 -05:00
if ( $option === 'com_mokoshop' && $view === 'product' && $id > 0 ) {
$productData = $this -> loadShopProduct ( $id );
if ( $productData ) {
$doc -> setMetaData ( 'product:price:amount' , number_format (( float ) $productData -> price , 2 , '.' , '' ), 'property' );
$doc -> setMetaData ( 'product:price:currency' , $productData -> currency ?: 'USD' , 'property' );
2026-06-23 10:45:30 -05:00
$availability = (( int ) ( $productData -> stock_qty ?? 0 ) > 0 ) ? 'instock' : 'outofstock' ;
$doc -> setMetaData ( 'product:availability' , $availability , 'property' );
2026-06-21 10:20:38 -05:00
}
}
2026-06-23 10:45:30 -05:00
// Pinterest article:tag rich pins (from Joomla content tags)
2026-06-23 10:25:21 -05:00
if ( $option === 'com_content' && $view === 'article' && $id > 0 ) {
2026-06-28 14:47:34 -05:00
$db = Factory :: getContainer () -> get ( \Joomla\Database\DatabaseInterface :: class );
2026-06-23 10:45:30 -05:00
$tagQuery = $db -> getQuery ( true )
-> select ( $db -> quoteName ( 't.title' ))
-> from ( $db -> quoteName ( '#__tags' , 't' ))
-> join ( 'INNER' , $db -> quoteName ( '#__contentitem_tag_map' , 'm' )
. ' ON ' . $db -> quoteName ( 'm.tag_id' ) . ' = ' . $db -> quoteName ( 't.id' ))
-> where ( $db -> quoteName ( 'm.type_alias' ) . ' = ' . $db -> quote ( 'com_content.article' ))
-> where ( $db -> quoteName ( 'm.content_item_id' ) . ' = ' . $id )
-> where ( $db -> quoteName ( 't.published' ) . ' = 1' );
$db -> setQuery ( $tagQuery );
$tags = $db -> loadColumn ();
foreach ( $tags as $tag ) {
$doc -> setMetaData ( 'article:tag' , $tag , 'property' );
2026-06-23 10:25:21 -05:00
}
}
2026-05-30 20:37:50 -05:00
// 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 ));
2026-05-23 18:23:07 -05:00
// JSON-LD structured data
if ( $this -> params -> get ( 'jsonld_enabled' , 1 )) {
$imageUrl = $image ? $this -> resolveImageUrl ( $image ) : '' ;
2026-06-21 10:20:38 -05:00
if ( $option === 'com_mokoshop' && $view === 'product' && $id > 0 ) {
2026-06-21 15:40:01 -05:00
$schema = JsonLdBuilder :: buildProduct ( $id , $title , $description , $imageUrl , $this -> loadShopProduct ( $id ));
2026-06-21 10:20:38 -05:00
} elseif ( $option === 'com_content' && $view === 'article' && $id > 0 ) {
2026-06-21 11:09:52 -05:00
$schema = JsonLdBuilder :: buildArticle ( $id , $title , $description , $imageUrl , $this -> loadArticle ( $id ));
2026-05-23 18:23:07 -05:00
} else {
$schema = JsonLdBuilder :: buildWebPage ( $title , $description );
}
if ( $schema ) {
$doc -> addCustomTag ( JsonLdBuilder :: toScriptTag ( $schema ));
}
2026-06-23 11:24:56 -05:00
if ( ! empty ( $ogData -> og_video )) {
$videoSchema = JsonLdBuilder :: buildVideo ( $ogData -> og_video , $title , $description , $imageUrl );
if ( $videoSchema ) {
$doc -> addCustomTag ( JsonLdBuilder :: toScriptTag ( $videoSchema ));
}
}
2026-06-23 12:19:08 -05:00
// FAQ schema (auto-detected from article headings)
if ( $this -> params -> get ( 'jsonld_faq' , 1 ) && $option === 'com_content' && $view === 'article' && $id > 0 ) {
$faqItems = $this -> extractFaqFromContent ( $id );
if ( ! empty ( $faqItems )) {
$faqSchema = JsonLdBuilder :: buildFaq ( $faqItems );
if ( $faqSchema ) {
$doc -> addCustomTag ( JsonLdBuilder :: toScriptTag ( $faqSchema ));
}
}
}
// HowTo schema (auto-detected from ordered lists)
if ( $this -> params -> get ( 'jsonld_howto' , 1 ) && $option === 'com_content' && $view === 'article' && $id > 0 ) {
$howToSteps = $this -> extractHowToFromContent ( $id );
if ( ! empty ( $howToSteps )) {
$howToSchema = JsonLdBuilder :: buildHowTo ( $title , $howToSteps , $imageUrl );
if ( $howToSchema ) {
$doc -> addCustomTag ( JsonLdBuilder :: toScriptTag ( $howToSchema ));
}
}
}
// Event JSON-LD from per-article event data
$eventJson = $ogData -> event_data ?? '' ;
if ( ! empty ( $eventJson )) {
$eventObj = json_decode ( $eventJson );
if ( $eventObj && ! empty ( $eventObj -> event_start )) {
$eventSchema = JsonLdBuilder :: buildEvent ( $title , $description , $imageUrl , $eventObj );
if ( $eventSchema ) {
$doc -> addCustomTag ( JsonLdBuilder :: toScriptTag ( $eventSchema ));
}
}
}
// Recipe JSON-LD from per-article recipe data
$recipeJson = $ogData -> recipe_data ?? '' ;
if ( ! empty ( $recipeJson )) {
$recipeObj = json_decode ( $recipeJson );
if ( $recipeObj ) {
$recipeSchema = JsonLdBuilder :: buildRecipe ( $title , $description , $imageUrl , $recipeObj );
if ( $recipeSchema ) {
$doc -> addCustomTag ( JsonLdBuilder :: toScriptTag ( $recipeSchema ));
}
}
}
2026-06-23 12:53:47 -05:00
// Custom JSON-LD schema (user-provided)
$customSchema = $ogData -> custom_schema ?? '' ;
if ( ! empty ( $customSchema )) {
$decoded = json_decode ( $customSchema , true );
2026-06-29 09:35:36 -05:00
// Guard against scalar/invalid payloads — only arrays/objects are
// valid JSON-LD. Writing an array offset onto a scalar is fatal.
if ( \is_array ( $decoded ) && $decoded !== []) {
2026-06-23 12:53:47 -05:00
if ( empty ( $decoded [ '@context' ])) {
$decoded [ '@context' ] = 'https://schema.org' ;
}
$doc -> addCustomTag ( JsonLdBuilder :: toScriptTag ( $decoded ));
}
}
2026-05-23 18:23:07 -05:00
if ( $this -> params -> get ( 'jsonld_breadcrumbs' , 1 )) {
$breadcrumbs = JsonLdBuilder :: buildBreadcrumbs ();
if ( $breadcrumbs ) {
$doc -> addCustomTag ( JsonLdBuilder :: toScriptTag ( $breadcrumbs ));
}
}
}
2026-06-23 11:27:59 -05:00
// LocalBusiness JSON-LD
if ( $this -> params -> get ( 'lb_enabled' , 0 )) {
$lbSchema = JsonLdBuilder :: buildLocalBusiness ( $this -> params );
if ( $lbSchema ) {
$doc -> addCustomTag ( JsonLdBuilder :: toScriptTag ( $lbSchema ));
}
}
2026-05-23 17:10:17 -05:00
}
2026-05-23 17:50:28 -05:00
/**
* 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 )) {
2026-06-21 10:02:03 -05:00
// Remove any existing canonical link via public API
$headData = $doc -> getHeadData ();
if ( ! empty ( $headData [ 'links' ])) {
foreach ( $headData [ 'links' ] as $link => $attribs ) {
if ( isset ( $attribs [ 'relation' ]) && $attribs [ 'relation' ] === 'canonical' ) {
unset ( $headData [ 'links' ][ $link ]);
}
2026-05-23 17:50:28 -05:00
}
2026-06-21 10:02:03 -05:00
$doc -> setHeadData ( $headData );
2026-05-23 17:50:28 -05:00
}
$doc -> addHeadLink ( $ogData -> canonical_url , 'canonical' );
}
}
2026-05-23 17:10:17 -05:00
/**
* 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 ) [
2026-05-23 17:50:28 -05:00
'og_title' => '' ,
'og_description' => '' ,
'og_image' => '' ,
'og_type' => '' ,
2026-06-23 10:25:21 -05:00
'og_video' => '' ,
2026-06-23 12:19:08 -05:00
'event_data' => '' ,
'recipe_data' => '' ,
2026-06-23 12:53:47 -05:00
'custom_schema' => '' ,
2026-05-23 17:50:28 -05:00
'seo_title' => '' ,
'meta_description' => '' ,
'robots' => '' ,
'canonical_url' => '' ,
2026-05-23 17:10:17 -05:00
];
if ( ! $id ) {
// Try menu-item-based lookup
$menuItem = $this -> getApplication () -> getMenu () -> getActive ();
if ( ! $menuItem ) {
return $empty ;
}
return $this -> loadOgDataByMenu (( int ) $menuItem -> id ) ?: $empty ;
}
2026-06-28 14:47:34 -05:00
$db = Factory :: getContainer () -> get ( \Joomla\Database\DatabaseInterface :: class );
2026-05-23 17:10:17 -05:00
$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 )
2026-05-23 18:35:49 -05:00
-> where ( $db -> quoteName ( 'published' ) . ' = 1' )
2026-06-29 09:52:51 -05:00
-> where ( '(' . $db -> quoteName ( 'language' ) . ' = ' . $db -> quote ( $this -> getApplication () -> getLanguage () -> getTag ())
2026-05-23 18:35:49 -05:00
. ' OR ' . $db -> quoteName ( 'language' ) . ' = ' . $db -> quote ( '*' ) . ')' )
-> order ( 'CASE WHEN ' . $db -> quoteName ( 'language' ) . ' = ' . $db -> quote ( '*' ) . ' THEN 1 ELSE 0 END ASC' );
2026-05-23 17:10:17 -05:00
2026-05-23 18:35:49 -05:00
$db -> setQuery ( $query , 0 , 1 );
2026-05-23 17:10:17 -05:00
return $db -> loadObject () ?: $empty ;
}
2026-05-23 18:10:53 -05:00
/**
* 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
{
2026-06-28 14:47:34 -05:00
$db = Factory :: getContainer () -> get ( \Joomla\Database\DatabaseInterface :: class );
2026-06-29 09:52:51 -05:00
$lang = $this -> getApplication () -> getLanguage () -> getTag ();
2026-06-21 10:02:03 -05:00
2026-05-23 18:10:53 -05:00
$query = $db -> getQuery ( true )
-> select ( '*' )
-> from ( $db -> quoteName ( '#__mokoog_tags' ))
-> where ( $db -> quoteName ( 'content_type' ) . ' = ' . $db -> quote ( $contentType ))
-> where ( $db -> quoteName ( 'content_id' ) . ' = ' . $contentId )
2026-06-21 10:02:03 -05:00
-> where ( $db -> quoteName ( 'published' ) . ' = 1' )
-> where ( '(' . $db -> quoteName ( 'language' ) . ' = ' . $db -> quote ( $lang )
. ' OR ' . $db -> quoteName ( 'language' ) . ' = ' . $db -> quote ( '*' ) . ')' )
-> order ( 'CASE WHEN ' . $db -> quoteName ( 'language' ) . ' = ' . $db -> quote ( '*' ) . ' THEN 1 ELSE 0 END ASC' );
2026-05-23 18:10:53 -05:00
2026-06-21 10:02:03 -05:00
$db -> setQuery ( $query , 0 , 1 );
2026-05-23 18:10:53 -05:00
return $db -> loadObject ();
}
2026-05-23 17:10:17 -05:00
/**
* Load OG data by menu item ID.
*
* @param int $menuId Menu item ID
*
* @return object|null
*/
private function loadOgDataByMenu ( int $menuId ) : ? object
{
2026-06-28 14:47:34 -05:00
$db = Factory :: getContainer () -> get ( \Joomla\Database\DatabaseInterface :: class );
2026-06-29 09:52:51 -05:00
$lang = $this -> getApplication () -> getLanguage () -> getTag ();
2026-06-21 10:02:03 -05:00
2026-05-23 17:10:17 -05:00
$query = $db -> getQuery ( true )
-> select ( '*' )
-> from ( $db -> quoteName ( '#__mokoog_tags' ))
-> where ( $db -> quoteName ( 'content_type' ) . ' = ' . $db -> quote ( 'menu' ))
-> where ( $db -> quoteName ( 'content_id' ) . ' = ' . $menuId )
2026-06-21 10:02:03 -05:00
-> where ( $db -> quoteName ( 'published' ) . ' = 1' )
-> where ( '(' . $db -> quoteName ( 'language' ) . ' = ' . $db -> quote ( $lang )
. ' OR ' . $db -> quoteName ( 'language' ) . ' = ' . $db -> quote ( '*' ) . ')' )
-> order ( 'CASE WHEN ' . $db -> quoteName ( 'language' ) . ' = ' . $db -> quote ( '*' ) . ' THEN 1 ELSE 0 END ASC' );
2026-05-23 17:10:17 -05:00
2026-06-21 10:02:03 -05:00
$db -> setQuery ( $query , 0 , 1 );
2026-05-23 17:10:17 -05:00
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 ));
2026-06-06 07:12:40 -05:00
if ( mb_strlen ( $description ) > $maxLength ) {
2026-05-23 17:10:17 -05:00
$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' , '' );
}
2026-06-21 10:20:38 -05:00
// For MokoSuiteShop products, look at the linked article's images
if ( $option === 'com_mokoshop' && $id > 0 ) {
$productData = $this -> loadShopProduct ( $id );
if ( $productData && ! empty ( $productData -> images )) {
$imagesData = json_decode ( $productData -> images , true );
if ( ! empty ( $imagesData [ 'image_fulltext' ])) {
return $imagesData [ 'image_fulltext' ];
}
if ( ! empty ( $imagesData [ 'image_intro' ])) {
return $imagesData [ 'image_intro' ];
}
}
return $this -> params -> get ( 'default_image' , '' );
}
2026-05-23 17:10:17 -05:00
// For Joomla articles, look at the intro/full image fields
if ( $option === 'com_content' && $id > 0 ) {
2026-06-21 11:09:52 -05:00
$article = $this -> loadArticle ( $id );
2026-05-23 17:10:17 -05:00
2026-06-21 11:09:52 -05:00
if ( $article && ! empty ( $article -> images )) {
$imagesData = json_decode ( $article -> images , true );
2026-05-23 17:10:17 -05:00
if ( ! empty ( $imagesData [ 'image_fulltext' ])) {
return $imagesData [ 'image_fulltext' ];
}
if ( ! empty ( $imagesData [ 'image_intro' ])) {
return $imagesData [ 'image_intro' ];
}
}
2026-05-23 18:10:53 -05:00
// Fallback: check the article's category for an image
if ( $view === 'article' ) {
2026-06-28 14:47:34 -05:00
$db = Factory :: getContainer () -> get ( \Joomla\Database\DatabaseInterface :: class );
2026-05-23 18:10:53 -05:00
$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' ];
}
}
}
2026-05-23 17:10:17 -05:00
}
return $this -> params -> get ( 'default_image' , '' );
}
/**
2026-05-23 17:57:07 -05:00
* Resolve a relative image path to a full URL, resizing for OG if needed.
2026-05-23 17:10:17 -05:00
*
* @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 ;
}
2026-05-23 17:57:07 -05:00
// Auto-resize to OG recommended dimensions if enabled
if ( $this -> params -> get ( 'auto_resize' , 1 )) {
$image = ImageHelper :: resize ( $image );
}
2026-05-23 17:10:17 -05:00
return rtrim ( Uri :: root (), '/' ) . '/' . ltrim ( $image , '/' );
}
2026-05-30 20:37:50 -05:00
2026-06-21 11:09:52 -05:00
/**
* Load and cache a full article record with author for the current request.
*
* @param int $id Article ID
*
* @return object|null
*/
private function loadArticle ( int $id ) : ? object
{
static $cache = [];
2026-06-29 09:52:51 -05:00
// array_key_exists (not isset) so a negative lookup (null) is also cached
// and not re-queried on every call within the request.
if ( \array_key_exists ( $id , $cache )) {
2026-06-21 11:09:52 -05:00
return $cache [ $id ];
}
2026-06-28 14:47:34 -05:00
$db = Factory :: getContainer () -> get ( \Joomla\Database\DatabaseInterface :: class );
2026-06-21 11:09:52 -05:00
$query = $db -> getQuery ( true )
-> select ( $db -> quoteName ([
'a.title' , 'a.introtext' , 'a.fulltext' , 'a.images' ,
'a.created' , 'a.modified' , 'a.publish_up' , 'a.metadesc' ,
]))
-> select ( $db -> quoteName ( 'u.name' , 'author_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 );
$cache [ $id ] = $db -> loadObject ();
return $cache [ $id ];
}
2026-05-30 20:37:50 -05:00
/**
* 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
{
2026-06-21 11:09:52 -05:00
$article = $this -> loadArticle ( $id );
2026-06-29 09:52:51 -05:00
$value = $article -> $field ?? '' ;
2026-05-30 20:37:50 -05:00
2026-06-29 09:52:51 -05:00
// Skip zero/empty dates — emitting "0000-00-00 00:00:00" as
// article:published_time/modified_time produces invalid metadata.
if ( $value === '' || str_starts_with ( $value , '0000-00-00' )) {
return '' ;
}
return $value ;
2026-05-30 20:37:50 -05:00
}
/**
* Get the author name for an article.
*
* @param int $id Article ID
*
* @return string
*/
private function getArticleAuthor ( int $id ) : string
{
2026-06-21 11:09:52 -05:00
$article = $this -> loadArticle ( $id );
2026-05-30 20:37:50 -05:00
2026-06-21 11:09:52 -05:00
return $article -> author_name ?? '' ;
2026-05-30 20:37:50 -05:00
}
2026-06-06 07:12:40 -05:00
2026-06-23 12:19:08 -05:00
/**
* Extract FAQ question/answer pairs from article content.
*
* @param int $articleId Article ID
*
* @return array Array of ['question' => '...', 'answer' => '...'] pairs
*/
private function extractFaqFromContent ( int $articleId ) : array
{
$article = $this -> loadArticle ( $articleId );
if ( ! $article ) {
return [];
}
$content = ( $article -> introtext ?? '' ) . ( $article -> fulltext ?? '' );
if ( trim ( $content ) === '' ) {
return [];
}
$faqItems = [];
if ( preg_match_all ( '/<h[34][^>]*>(.*?)<\/h[34]>\s*((?:<p[^>]*>.*?<\/p>\s*)+)/si' , $content , $matches , PREG_SET_ORDER )) {
foreach ( $matches as $match ) {
$question = trim ( strip_tags ( $match [ 1 ]));
$answer = trim ( strip_tags ( $match [ 2 ]));
if ( $question !== '' && $answer !== '' ) {
$faqItems [] = [
'question' => $question ,
'answer' => $answer ,
];
}
}
}
return $faqItems ;
}
/**
* Extract HowTo steps from ordered lists in article content.
*
* @param int $articleId Article ID
*
* @return array Array of ['name' => '...', 'text' => '...'] pairs
*/
private function extractHowToFromContent ( int $articleId ) : array
{
$article = $this -> loadArticle ( $articleId );
if ( ! $article ) {
return [];
}
$content = ( $article -> introtext ?? '' ) . ( $article -> fulltext ?? '' );
if ( ! preg_match ( '/<ol[^>]*>(.*?)<\/ol>/si' , $content , $olMatch )) {
return [];
}
if ( ! preg_match_all ( '/<li[^>]*>(.*?)<\/li>/si' , $olMatch [ 1 ], $liMatches )) {
return [];
}
$steps = [];
foreach ( $liMatches [ 1 ] as $liHtml ) {
$text = trim ( strip_tags ( $liHtml ));
if ( $text === '' ) {
continue ;
}
$name = $text ;
if ( preg_match ( '/<(?:b|strong)[^>]*>(.*?)<\/(?:b|strong)>/si' , $liHtml , $boldMatch )) {
$name = trim ( strip_tags ( $boldMatch [ 1 ]));
} elseif ( preg_match ( '/^([^.!?]+[.!?])/' , $text , $sentenceMatch )) {
$name = trim ( $sentenceMatch [ 1 ]);
}
$steps [] = [
'name' => $name ,
'text' => $text ,
];
}
return $steps ;
}
2026-06-23 12:53:47 -05:00
/**
* Rebuild sitemap.xml when article content is saved.
*
* @param Event $event The event
*
* @return void
*/
public function onContentAfterSaveRebuildSitemap ( Event $event ) : void
{
2026-06-29 10:25:59 -05:00
// Opportunistic maintenance on content save: prune stale generated images
// so the generated-image cache cannot grow without bound.
ImageHelper :: pruneOldFiles ();
2026-06-23 12:53:47 -05:00
if ( ! $this -> params -> get ( 'sitemap_enabled' , 0 )) {
return ;
}
[ $context ] = array_values ( $event -> getArguments ());
if ( $context !== 'com_content.article' ) {
return ;
}
$changefreq = $this -> params -> get ( 'sitemap_changefreq' , 'weekly' );
$xml = SitemapBuilder :: generate ( $changefreq );
2026-06-28 13:54:53 -05:00
if ( ! SitemapBuilder :: writeToFile ( $xml )) {
\Joomla\CMS\Log\Log :: add ( 'MokoOG: Failed to write sitemap.xml — check file permissions' , \Joomla\CMS\Log\Log :: WARNING , 'mokoog' );
}
2026-06-23 12:53:47 -05:00
}
/**
* Handle AJAX requests for AI meta tag generation.
*
* @param Event $event The event
*
* @return void
*/
public function onAjaxMokoog ( Event $event ) : void
{
$app = $this -> getApplication ();
if ( ! $app -> isClient ( 'administrator' )) {
return ;
}
2026-06-28 13:54:53 -05:00
if ( ! \Joomla\CMS\Session\Session :: checkToken ()) {
$event -> setArgument ( 'result' , [ 'Invalid Token' ]);
return ;
}
2026-06-23 12:53:47 -05:00
2026-06-29 09:52:51 -05:00
// Require article-edit capability — this triggers outbound paid AI calls,
// so it must not be reachable by every authenticated back-end user.
if ( ! $app -> getIdentity () -> authorise ( 'core.edit' , 'com_content' )
&& ! $app -> getIdentity () -> authorise ( 'core.create' , 'com_content' )) {
$event -> setArgument ( 'result' , [ 'Forbidden — insufficient permissions' ]);
return ;
}
2026-06-23 12:53:47 -05:00
if ( ! $this -> params -> get ( 'ai_enabled' , 0 )) {
$event -> setArgument ( 'result' , [ 'AI generation is not enabled' ]);
return ;
}
$apiKey = $this -> params -> get ( 'ai_api_key' , '' );
$provider = $this -> params -> get ( 'ai_provider' , 'claude' );
$model = $this -> params -> get ( 'ai_model' , 'claude-haiku-4-5-20251001' );
if ( empty ( $apiKey )) {
$event -> setArgument ( 'result' , [ 'API key not configured' ]);
return ;
}
$input = $app -> getInput ();
$field = $input -> getString ( 'field' , 'title' );
2026-06-28 13:54:53 -05:00
$articleTitle = mb_substr ( strip_tags ( $input -> getString ( 'article_title' , '' )), 0 , 200 );
2026-06-23 12:53:47 -05:00
$prompt = $field === 'title'
? "Generate a concise, engaging social media sharing title (max 60 characters) for an article titled: \" $articleTitle\ " . Return only the title text , no quotes or explanation . "
: " Generate a compelling social media sharing description ( max 155 characters ) for an article titled : \ " $articleTitle\ " . Return only the description text , no quotes or explanation . ";
try {
$result = $this->callAiApi ( $provider , $apiKey , $model , $prompt );
$event->setArgument ('result', [ $result ]);
} catch (\Exception $e ) {
$event->setArgument ('result', ['Error: ' . $e->getMessage ()]);
}
}
/**
* Call an AI API (Claude or OpenAI) with a prompt.
*
* @param string $provider Provider name (claude or openai)
* @param string $apiKey API key
* @param string $model Model name
* @param string $prompt Prompt text
*
* @return string Generated text
*/
private function callAiApi(string $provider , string $apiKey , string $model , string $prompt ): string
{
$http = \Joomla\CMS\Http\HttpFactory::getHttp();
2026-06-29 09:52:51 -05:00
// Cap how long a hung provider can block the admin request.
$timeout = 20;
2026-06-23 12:53:47 -05:00
if ( $provider === 'claude') {
$response = $http->post (
'https://api.anthropic.com/v1/messages',
json_encode([
'model' => $model ,
'max_tokens' => 200,
'messages' => [['role' => 'user', 'content' => $prompt ]],
]),
[
'Content-Type' => 'application/json',
'x-api-key' => $apiKey ,
'anthropic-version' => '2023-06-01',
2026-06-29 09:52:51 -05:00
],
$timeout
2026-06-23 12:53:47 -05:00
);
2026-06-29 09:52:51 -05:00
if ((int) $response->code !== 200) {
throw new \R untimeException('Claude API request failed (HTTP ' . (int) $response->code . ')');
}
2026-06-23 12:53:47 -05:00
$data = json_decode( $response->body , true);
return trim( $data['content'] [0]['text'] ?? '');
}
$response = $http->post (
'https://api.openai.com/v1/chat/completions',
json_encode([
'model' => $model ,
'max_tokens' => 200,
'messages' => [['role' => 'user', 'content' => $prompt ]],
]),
[
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $apiKey ,
2026-06-29 09:52:51 -05:00
],
$timeout
2026-06-23 12:53:47 -05:00
);
2026-06-29 09:52:51 -05:00
if ((int) $response->code !== 200) {
throw new \R untimeException('OpenAI API request failed (HTTP ' . (int) $response->code . ')');
}
2026-06-23 12:53:47 -05:00
$data = json_decode( $response->body , true);
return trim( $data['choices'] [0]['message']['content'] ?? '');
}
2026-06-06 07:12:40 -05:00
/**
* Warn administrators once per session when no license key is configured.
*
* @return void
*/
private function warnMissingLicenseKey(): void
{
2026-06-28 14:47:34 -05:00
$session = Factory::getApplication()->getSession();
2026-06-06 07:12:40 -05:00
if ( $session->get ('mokoog.license_warned', false)) {
return;
}
2026-06-28 14:47:34 -05:00
$user = Factory::getApplication()->getIdentity();
2026-06-06 07:12:40 -05:00
if ( $user->guest || ! $user->authorise ('core.manage')) {
return;
}
try {
2026-06-28 14:47:34 -05:00
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
2026-06-06 07:12:40 -05:00
$query = $db->getQuery (true)
->select( $db->quoteName ('extra_query'))
->from( $db->quoteName ('#__update_sites'))
2026-06-21 10:02:03 -05:00
->where( $db->quoteName ('name') . ' = ' . $db->quote ('MokoSuiteOpenGraph Updates'))
2026-06-06 07:12:40 -05:00
->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 '
2026-06-21 10:02:03 -05:00
. 'for the MokoSuiteOpenGraph update site.',
2026-06-06 07:12:40 -05:00
'warning'
);
} catch ( \T hrowable $e ) {
2026-06-21 16:26:13 -05:00
// Don't break admin over a license check, but log for debugging
\Joomla\CMS\Log\Log::add('MokoOG license check: ' . $e->getMessage (), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
2026-06-06 07:12:40 -05:00
}
}
2026-06-21 10:20:38 -05:00
/**
* Load MokoSuiteShop product data by product ID.
*
* @param int $productId CRM product ID
*
* @return object|null Product with name, description, images, price, currency, sku
*/
private function loadShopProduct(int $productId ): ?object
{
static $cache = [];
if (isset( $cache[$productId] )) {
return $cache[$productId] ;
}
2026-06-21 16:26:13 -05:00
try {
2026-06-28 14:47:34 -05:00
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
2026-06-21 16:26:13 -05:00
$query = $db->getQuery (true)
->select('p.id, p.sku, p.price, p.currency, p.stock_qty')
->select('c.title AS name, c.introtext AS description, c.images')
->from( $db->quoteName ('#__mokosuite_crm_products', 'p'))
->join('LEFT', $db->quoteName ('#__content', 'c') . ' ON c.id = p.article_id')
->where( $db->quoteName ('p.id') . ' = ' . $productId )
->where( $db->quoteName ('p.published') . ' = 1');
2026-06-21 10:20:38 -05:00
2026-06-21 16:26:13 -05:00
$db->setQuery ( $query );
$cache[$productId] = $db->loadObject ();
} catch ( \R untimeException $e ) {
// MokoSuiteShop tables may not exist
$cache[$productId] = null;
}
2026-06-21 10:20:38 -05:00
return $cache[$productId] ;
}
2026-06-21 15:40:01 -05:00
/**
* Get the actual pixel dimensions of a local image.
*
* @param string $image Image path (relative or absolute URL)
*
* @return array{0: int, 1: int}|null
*/
2026-06-06 07:12:40 -05:00
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;
}
2026-06-21 16:42:54 -05:00
$info = getimagesize( $absPath );
2026-06-06 07:12:40 -05:00
if (! $info ) {
return null;
}
return [ $info[0] , $info[1] ];
}
2026-05-23 17:10:17 -05:00
}