fcfa6838e5
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 14s
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 15s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 54s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- Only emit og:video:secure_url for HTTPS URLs (review #1) - Only emit og:video:width/height for direct files, not embeds (review #2) - Add server-side http/https scheme validation on og_video save (review #3) - Consolidate duplicate com_mokoshop product blocks into one (review #4) - Fix stale com_virtuemart reference in SQL comment (review #5) - Use COM_MOKOOG_* language keys in tag.xml instead of plugin keys (review #6)
312 lines
10 KiB
PHP
312 lines
10 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoJoomOpenGraph
|
|
* @subpackage plg_content_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\Content\MokoOG\Extension;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Form\Form;
|
|
use Joomla\CMS\Plugin\CMSPlugin;
|
|
use Joomla\Event\Event;
|
|
use Joomla\Event\SubscriberInterface;
|
|
|
|
final class MokoOGContent 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 [
|
|
'onContentPrepareForm' => 'onContentPrepareForm',
|
|
'onContentAfterSave' => 'onContentAfterSave',
|
|
'onContentAfterDelete' => 'onContentAfterDelete',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Add Open Graph fields to the article edit form.
|
|
*
|
|
* @param \Joomla\Event\Event $event The event
|
|
*
|
|
* @return void
|
|
*/
|
|
public function onContentPrepareForm(Event $event): void
|
|
{
|
|
[$form, $data] = array_values($event->getArguments());
|
|
|
|
if (!$form instanceof Form) {
|
|
return;
|
|
}
|
|
|
|
$formName = $form->getName();
|
|
|
|
// 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)) {
|
|
return;
|
|
}
|
|
|
|
// Load the OG fields form XML
|
|
$formPath = JPATH_PLUGINS . '/content/mokoog/forms';
|
|
Form::addFormPath($formPath);
|
|
$form->loadFile('mokoog', false);
|
|
|
|
// Load live preview assets
|
|
$wa = $this->getApplication()->getDocument()->getWebAssetManager();
|
|
$wa->getRegistry()->addRegistryFile('media/plg_content_mokoog/joomla.asset.json');
|
|
$wa->useStyle('plg_content_mokoog.preview');
|
|
$wa->useScript('plg_content_mokoog.preview');
|
|
|
|
// If editing an existing item, load saved OG data
|
|
$id = 0;
|
|
|
|
if (\is_object($data) && isset($data->id)) {
|
|
$id = (int) $data->id;
|
|
} elseif (\is_array($data) && isset($data['id'])) {
|
|
$id = (int) $data['id'];
|
|
}
|
|
|
|
if ($id > 0) {
|
|
$formTypeMap = [
|
|
'com_content.article' => 'com_content',
|
|
'com_menus.item' => 'menu',
|
|
'com_categories.categorycom_content' => 'com_content.category',
|
|
];
|
|
$contentType = $formTypeMap[$formName] ?? 'com_content';
|
|
$language = $this->getContentLanguage($data);
|
|
$ogData = $this->loadOgData($contentType, $id, $language);
|
|
|
|
if ($ogData) {
|
|
$form->bind(['mokoog' => (array) $ogData]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save OG data when content is saved.
|
|
*
|
|
* @param \Joomla\Event\Event $event The event
|
|
*
|
|
* @return void
|
|
*/
|
|
public function onContentAfterSave(Event $event): void
|
|
{
|
|
[$context, $article, $isNew] = array_values($event->getArguments());
|
|
|
|
$supportedContexts = [
|
|
'com_content.article' => 'com_content',
|
|
'com_menus.item' => 'menu',
|
|
'com_categories.categorycom_content' => 'com_content.category',
|
|
];
|
|
|
|
if (!isset($supportedContexts[$context])) {
|
|
return;
|
|
}
|
|
|
|
// Only process saves from the admin HTTP interface where form data is available
|
|
$app = $this->getApplication();
|
|
|
|
if (!$app->isClient('administrator')) {
|
|
return;
|
|
}
|
|
|
|
$contentType = $supportedContexts[$context];
|
|
$contentId = (int) $article->id;
|
|
$language = $this->getContentLanguage($article);
|
|
|
|
$input = $app->getInput();
|
|
$jform = $input->get('jform', [], 'array');
|
|
$ogData = $jform['mokoog'] ?? [];
|
|
|
|
if (empty($ogData)) {
|
|
return;
|
|
}
|
|
|
|
$this->saveOgData($contentType, $contentId, $ogData, $language);
|
|
}
|
|
|
|
/**
|
|
* Remove OG data when content is deleted.
|
|
*
|
|
* @param \Joomla\Event\Event $event The event
|
|
*
|
|
* @return void
|
|
*/
|
|
public function onContentAfterDelete(Event $event): void
|
|
{
|
|
[$context, $article] = array_values($event->getArguments());
|
|
|
|
$supportedContexts = [
|
|
'com_content.article' => 'com_content',
|
|
'com_menus.item' => 'menu',
|
|
'com_categories.categorycom_content' => 'com_content.category',
|
|
];
|
|
|
|
if (!isset($supportedContexts[$context])) {
|
|
return;
|
|
}
|
|
|
|
$contentType = $supportedContexts[$context];
|
|
$contentId = (int) $article->id;
|
|
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->delete($db->quoteName('#__mokoog_tags'))
|
|
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
|
->where($db->quoteName('content_id') . ' = ' . $contentId);
|
|
|
|
$db->setQuery($query);
|
|
$db->execute();
|
|
}
|
|
|
|
/**
|
|
* Load existing OG data for a content item, filtered by language.
|
|
*
|
|
* @param string $contentType Content type identifier
|
|
* @param int $contentId Content ID
|
|
* @param string $language Language tag (e.g. 'en-GB') or '*' for all
|
|
*
|
|
* @return object|null
|
|
*/
|
|
private function loadOgData(string $contentType, int $contentId, string $language = '*'): ?object
|
|
{
|
|
$db = Factory::getDbo();
|
|
$query = $db->getQuery(true)
|
|
->select($db->quoteName([
|
|
'og_title', 'og_description', 'og_image', 'og_type', 'og_video',
|
|
'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)
|
|
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($language)
|
|
. ' 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();
|
|
}
|
|
|
|
/**
|
|
* Save or update OG data for a content item.
|
|
*
|
|
* @param string $contentType Content type identifier
|
|
* @param int $contentId Content ID
|
|
* @param array $ogData OG field values
|
|
* @param string $language Language tag (e.g. 'en-GB') or '*' for all
|
|
*
|
|
* @return void
|
|
*/
|
|
private function saveOgData(string $contentType, int $contentId, array $ogData, string $language = '*'): void
|
|
{
|
|
$db = Factory::getDbo();
|
|
|
|
// Check if record exists for this content + language
|
|
$query = $db->getQuery(true)
|
|
->select('id')
|
|
->from($db->quoteName('#__mokoog_tags'))
|
|
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
|
->where($db->quoteName('content_id') . ' = ' . $contentId)
|
|
->where($db->quoteName('language') . ' = ' . $db->quote($language));
|
|
|
|
$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,
|
|
'language' => $language,
|
|
'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'),
|
|
'og_video' => $this->sanitizeUrl($ogData['og_video'] ?? ''),
|
|
'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) {
|
|
$record->id = $existingId;
|
|
$db->updateObject('#__mokoog_tags', $record, 'id');
|
|
} else {
|
|
$record->created = Factory::getDate()->toSql();
|
|
$db->insertObject('#__mokoog_tags', $record);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize a URL to only allow http/https schemes.
|
|
*
|
|
* @param string $url Raw URL value
|
|
*
|
|
* @return string Sanitized URL or empty string
|
|
*/
|
|
private function sanitizeUrl(string $url): string
|
|
{
|
|
$url = trim($url);
|
|
|
|
if ($url === '') {
|
|
return '';
|
|
}
|
|
|
|
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
|
return '';
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
/**
|
|
* Extract the language tag from content data.
|
|
*
|
|
* @param object|array $data Content data from form or article object
|
|
*
|
|
* @return string Language tag (e.g. 'en-GB') or '*' for all languages
|
|
*/
|
|
private function getContentLanguage($data): string
|
|
{
|
|
$language = '*';
|
|
|
|
if (\is_object($data) && isset($data->language)) {
|
|
$language = $data->language;
|
|
} elseif (\is_array($data) && isset($data['language'])) {
|
|
$language = $data['language'];
|
|
}
|
|
|
|
return !empty($language) ? $language : '*';
|
|
}
|
|
}
|