Files
MokoSuiteOpenGraph/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php
T
Jonathan Miller 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
fix: address PR #82 review findings
- 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)
2026-06-23 10:46:58 -05:00

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 : '*';
}
}