diff --git a/administrator/components/com_content/src/Extension/ContentComponent.php b/administrator/components/com_content/src/Extension/ContentComponent.php index bd2861e1fa4..efc9e76c6f3 100644 --- a/administrator/components/com_content/src/Extension/ContentComponent.php +++ b/administrator/components/com_content/src/Extension/ContentComponent.php @@ -25,6 +25,7 @@ use Joomla\CMS\Helper\ContentHelper as LibraryContentHelper; use Joomla\CMS\HTML\HTMLRegistryAwareTrait; use Joomla\CMS\Language\Text; +use Joomla\CMS\Opengraph\OpengraphServiceInterface; use Joomla\CMS\Schemaorg\SchemaorgServiceInterface; use Joomla\CMS\Schemaorg\SchemaorgServiceTrait; use Joomla\CMS\Tag\TagServiceInterface; @@ -53,7 +54,8 @@ class ContentComponent extends MVCComponent implements SchemaorgServiceInterface, WorkflowServiceInterface, RouterServiceInterface, - TagServiceInterface + TagServiceInterface, + OpengraphServiceInterface { use AssociationServiceTrait; use RouterServiceTrait; @@ -204,6 +206,65 @@ public function getSchemaorgContexts(): array return $contexts; } + + /** + * Returns a grouped list of mappable fields used by the OpengraphField. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getOpengraphFields(): array + { + Factory::getApplication()->getLanguage()->load('com_content', JPATH_ADMINISTRATOR); + + $fields = [ + 'text-fields' => [ + 'title' => Text::_('JGLOBAL_TITLE'), + 'articletext' => Text::_('COM_CONTENT_FIELD_ARTICLETEXT_LABEL'), + 'alias' => Text::_('JFIELD_ALIAS_LABEL'), + 'metadesc' => Text::_('JFIELD_META_DESCRIPTION_LABEL'), + ], + + 'image-fields' => [ + 'image_intro' => Text::_('COM_CONTENT_FIELD_INTRO_LABEL'), + 'image_fulltext' => Text::_('COM_CONTENT_FIELD_FULL_LABEL'), + + ], + + 'image-alt-fields' => [ + 'image_intro_alt' => Text::_('COM_CONTENT_FIELD_INTRO_LABEL') . ' - ' . Text::_('COM_CONTENT_FIELD_IMAGE_ALT_LABEL'), + 'image_fulltext_alt' => Text::_('COM_CONTENT_FIELD_FULL_LABEL') . ' - ' . Text::_('COM_CONTENT_FIELD_IMAGE_ALT_LABEL'), + ], + + 'meta-fields' => [ + 'metadesc' => Text::_('JFIELD_META_DESCRIPTION_LABEL'), + 'metakey' => Text::_('JFIELD_META_KEYWORDS_LABEL'), + ], + + + 'locale-fields' => [ + 'language' => Text::_('JFIELD_LANGUAGE_LABEL'), + ], + + 'author-fields' => [ + 'created_by' => Text::_('COM_CONTENT_FIELD_CREATED_BY_LABEL'), + 'created_by_alias' => Text::_('COM_CONTENT_FIELD_CREATED_BY_ALIAS_LABEL'), + 'modified_by' => Text::_('JGLOBAL_FIELD_MODIFIED_BY_LABEL'), + + ], + 'date-fields' => [ + 'created' => Text::_('COM_CONTENT_FIELD_CREATED_LABEL'), + 'modified' => Text::_('JGLOBAL_FIELD_MODIFIED_LABEL'), + 'publish_up' => Text::_('COM_CONTENT_FIELD_PUBLISH_UP_LABEL'), + 'publish_down' => Text::_('COM_CONTENT_FIELD_PUBLISH_DOWN_LABEL'), + ], + ]; + + return $fields; + } + + /** * Returns valid contexts * @@ -295,7 +356,6 @@ public function getModelName($context): string return ucfirst($modelname); } - /** * Method to filter transitions by given id of state. * diff --git a/administrator/components/com_menus/src/Extension/MenusComponent.php b/administrator/components/com_menus/src/Extension/MenusComponent.php index 084ca80159e..b6471744888 100644 --- a/administrator/components/com_menus/src/Extension/MenusComponent.php +++ b/administrator/components/com_menus/src/Extension/MenusComponent.php @@ -15,6 +15,7 @@ use Joomla\CMS\Extension\BootableExtensionInterface; use Joomla\CMS\Extension\MVCComponent; use Joomla\CMS\HTML\HTMLRegistryAwareTrait; +use Joomla\CMS\Opengraph\OpengraphServiceInterface; use Joomla\Component\Menus\Administrator\Service\HTML\Menus; use Psr\Container\ContainerInterface; @@ -29,7 +30,8 @@ */ class MenusComponent extends MVCComponent implements BootableExtensionInterface, - AssociationServiceInterface + AssociationServiceInterface, + OpengraphServiceInterface { use AssociationServiceTrait; use HTMLRegistryAwareTrait; @@ -51,4 +53,42 @@ public function boot(ContainerInterface $container) { $this->getRegistry()->register('menus', new Menus()); } + + + /** + * Returns a grouped list of mappable fields used by the OpengraphField. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public function getOpengraphFields(): array + { + + $fields = []; + + return $fields; + } + + /** + * Returns the model name, based on the context + * + * @param string $context + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + public function getModelName($context): string + { + $parts = explode('.', $context); + + if (\count($parts) < 2) { + return ''; + } + + array_shift($parts); + + return ucfirst(array_shift($parts)); + } } diff --git a/administrator/language/en-GB/plg_system_opengraph.ini b/administrator/language/en-GB/plg_system_opengraph.ini new file mode 100644 index 00000000000..a1e0c08d56c --- /dev/null +++ b/administrator/language/en-GB/plg_system_opengraph.ini @@ -0,0 +1,100 @@ +; Joomla! Project +; (C) 2025 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_SYSTEM_OPENGRAPH_ADVANCED_SECTION ="Advanced Settings" +PLG_SYSTEM_OPENGRAPH_DEFAULT_DESC_DESC="Fallback description for OpenGraph tags." +PLG_SYSTEM_OPENGRAPH_DEFAULT_DESC_LABEL="Default OG Description" +PLG_SYSTEM_OPENGRAPH_DEFAULT_IMAGE_ALT_DESC="Alt text for the default OpenGraph image." +PLG_SYSTEM_OPENGRAPH_DEFAULT_IMAGE_ALT_LABEL="Default OG Image Alt" +PLG_SYSTEM_OPENGRAPH_DEFAULT_IMAGE_DESC="Fallback image for OpenGraph (recommended 1200×630)." +PLG_SYSTEM_OPENGRAPH_DEFAULT_IMAGE_LABEL="Default OG Image" +PLG_SYSTEM_OPENGRAPH_DEFAULT_SITENAME_DESC="Fallback for og:site_name if you choose to include it." +PLG_SYSTEM_OPENGRAPH_DEFAULT_SITENAME_LABEL="Default Site Name" +PLG_SYSTEM_OPENGRAPH_DEFAULT_TITLE_DESC="Used when no title is mapped or provided by Menu/Category/Item." +PLG_SYSTEM_OPENGRAPH_DEFAULT_TITLE_LABEL="Default OG Title" +PLG_SYSTEM_OPENGRAPH_DESCRIPTION="Open Graph Plugin automatically generates Open Graph tags for improved social media sharing. Features hierarchical override system supporting global, category, and article-level metadata configuration." +PLG_SYSTEM_OPENGRAPH_DESCRIPTION_FIELD_DESC="Select which article field to use for og:description meta tag. Meta description is preferred over article text." +PLG_SYSTEM_OPENGRAPH_DESCRIPTION_FIELD_LABEL="Description Source" +PLG_SYSTEM_OPENGRAPH_ENABLE_DESC="Enable OpenGraph tags for this content." +PLG_SYSTEM_OPENGRAPH_ENABLE_LABEL="Enable OpenGraph" +PLG_SYSTEM_OPENGRAPH_ENABLE_OG_GENERATION="Enable OpenGraph" +PLG_SYSTEM_OPENGRAPH_ENABLE_OG_GENERATION_DESC="Enable OpenGraph tags generation." +PLG_SYSTEM_OPENGRAPH_FB_APP_ID_DESC="Needed only for Facebook Insights." +PLG_SYSTEM_OPENGRAPH_FB_APP_ID_LABEL="Facebook App ID" +PLG_SYSTEM_OPENGRAPH_FEATURES_DESC="• Generates complete Open Graph and Twitter Card metadata on the fly. • Hierarchical override system: Menu → Article → Category → Global. • Auto‑picks intro/full images and alt text. • Smart sanitisation & trimming prevents broken previews." +PLG_SYSTEM_OPENGRAPH_FEATURES_LABEL="What this plugin does" +PLG_SYSTEM_OPENGRAPH_FIELDSET_ARTICLE="OpenGraph" +PLG_SYSTEM_OPENGRAPH_FIELDSET_ARTICLE_DESC="Configure OpenGraph metadata for social media sharing. These settings override global defaults." +PLG_SYSTEM_OPENGRAPH_FIELDSET_GLOBAL_DEFAULTS="Global Defaults" +PLG_SYSTEM_OPENGRAPH_FIELDSET_GLOBAL_DEFAULTS_DESC="Set site-wide OpenGraph fallback values. Menu and item-level settings will override these." +PLG_SYSTEM_OPENGRAPH_FIELD_MAPPING_SECTION="Field Mapping Configuration" +PLG_SYSTEM_OPENGRAPH_GLOBAL_DESC="Global defaults used when no article / menu overrides are present." +PLG_SYSTEM_OPENGRAPH_GLOBAL_OG_SETTINGS="Global Open Graph Settings" +PLG_SYSTEM_OPENGRAPH_GROUP_DEFAULT_FIELDS="Default Fields" +PLG_SYSTEM_OPENGRAPH_IMAGE_ALT_FIELD_DESC="Select which article field to use for og:image:alt meta tag for accessibility" +PLG_SYSTEM_OPENGRAPH_IMAGE_ALT_FIELD_LABEL="Image Alt Text Source" +PLG_SYSTEM_OPENGRAPH_IMAGE_FIELD_DESC="Select which article field to use for og:image meta tag" +PLG_SYSTEM_OPENGRAPH_IMAGE_FIELD_LABEL="Image Source" +PLG_SYSTEM_OPENGRAPH_INHERITED="inherited from" +PLG_SYSTEM_OPENGRAPH_MANUAL_OVERRIDE_SECTION="Manual Override Options" +PLG_SYSTEM_OPENGRAPH_MAX_ALT_DESC="Recommended: ≤125 characters (based on WCAG guidelines)." +PLG_SYSTEM_OPENGRAPH_MAX_ALT_LABEL="Max Alt Text Length" +PLG_SYSTEM_OPENGRAPH_MAX_DESC_DESC="Recommended: ≤160 characters (Twitter truncates beyond this)." +PLG_SYSTEM_OPENGRAPH_MAX_DESC_LABEL="Max Description Length" +PLG_SYSTEM_OPENGRAPH_MAX_TITLE_DESC="Recommended: ≤60 characters for best display in Facebook and Twitter cards." +PLG_SYSTEM_OPENGRAPH_MAX_TITLE_LABEL="Max Title Length" +PLG_SYSTEM_OPENGRAPH_NO_FIELD_SELECTED="-- No Field Selected --" +PLG_SYSTEM_OPENGRAPH_OG_DESCRIPTION_DESC="Brief description for social media sharing. Recommended length: 120-160 characters." +PLG_SYSTEM_OPENGRAPH_OG_DESCRIPTION_HINT="Enter a brief, engaging description" +PLG_SYSTEM_OPENGRAPH_OG_DESCRIPTION_LABEL="OpenGraph Description" +PLG_SYSTEM_OPENGRAPH_OG_IMAGE_ALT_DESC="Alternative text for the OpenGraph image for accessibility." +PLG_SYSTEM_OPENGRAPH_OG_IMAGE_ALT_HINT="Enter a brief description of the image for accessibility" +PLG_SYSTEM_OPENGRAPH_OG_IMAGE_ALT_LABEL="OpenGraph Image Alt Text" +PLG_SYSTEM_OPENGRAPH_OG_IMAGE_DESC="Image for social media sharing. Recommended size: 1200x630 pixels." +PLG_SYSTEM_OPENGRAPH_OG_IMAGE_LABEL="OpenGraph Image" +PLG_SYSTEM_OPENGRAPH_OG_TITLE_DESC="Custom title for social media sharing. Recommended length: 40-60 characters." +PLG_SYSTEM_OPENGRAPH_OG_TITLE_HINT="Enter a compelling title for social sharing" +PLG_SYSTEM_OPENGRAPH_OG_TITLE_LABEL="OpenGraph Title" +PLG_SYSTEM_OPENGRAPH_OG_TYPE_DESC="Content type for social media platforms." +PLG_SYSTEM_OPENGRAPH_OG_TYPE_LABEL="OpenGraph Type" +PLG_SYSTEM_OPENGRAPH_OG_URL_DESC="Override the canonical URL for sharing." +PLG_SYSTEM_OPENGRAPH_OG_URL_HINT="Leave empty to use the article's URL" +PLG_SYSTEM_OPENGRAPH_OG_URL_LABEL="Custom URL" +PLG_SYSTEM_OPENGRAPH_OVERRIDE_SETTINGS="Override Settings" +PLG_SYSTEM_OPENGRAPH_OVERVIEW="Overview & Help" +PLG_SYSTEM_OPENGRAPH_QUICKSTART_DESC="1. Enable the plugin. 2. Optional: enter your Facebook App ID above. 3. Edit any article → ‘Open Graph’ tab to override title, description, or image. 4. Clear cache, share your URL and enjoy rich previews!" +PLG_SYSTEM_OPENGRAPH_QUICKSTART_LABEL="How to use" +PLG_SYSTEM_OPENGRAPH_SHOW_ADVANCED_DESC="Show advanced settings for OpenGraph metadata." +PLG_SYSTEM_OPENGRAPH_SHOW_ADVANCED_LABEL="Show Advanced Settings" +PLG_SYSTEM_OPENGRAPH_SHOW_MANUAL_OVERRIDE_DESC="Enable this to manually set OG tags like title, description, and image." +PLG_SYSTEM_OPENGRAPH_SHOW_MANUAL_OVERRIDE_LABEL="Show Manual Override Settings" +PLG_SYSTEM_OPENGRAPH_TIPS_DESC="• Changed an image but Facebook still shows the old one? Clear Joomla & CDN caches, then run Facebook Sharing Debugger. • Large images (>5 MB) may be ignored by Twitter. • Verify that og:image is an absolute URL (use the built‑in sanitiser)." +PLG_SYSTEM_OPENGRAPH_TIPS_LABEL="Troubleshooting" +PLG_SYSTEM_OPENGRAPH_TITLE_FIELD_DESC="Select which article field to use for og:title meta tag" +PLG_SYSTEM_OPENGRAPH_TITLE_FIELD_LABEL="Title Source" +PLG_SYSTEM_OPENGRAPH_TWITTER_CARD_DESC="Select the type of Twitter card to use for this content." +PLG_SYSTEM_OPENGRAPH_TWITTER_CARD_LABEL="Twitter Card Type" +PLG_SYSTEM_OPENGRAPH_TWITTER_CARD_SUMMARY="Summary" +PLG_SYSTEM_OPENGRAPH_TWITTER_CARD_SUMMARY_LARGE_IMAGE="Summary Large Image" +PLG_SYSTEM_OPENGRAPH_TWITTER_DESC_DESC="Custom description for Twitter sharing. Recommended length: 120-160 characters." +PLG_SYSTEM_OPENGRAPH_TWITTER_DESC_HINT="Enter a brief, engaging description" +PLG_SYSTEM_OPENGRAPH_TWITTER_DESC_LABEL="Twitter Description" +PLG_SYSTEM_OPENGRAPH_TWITTER_IMAGE_DESC="Image for Twitter sharing. Recommended size: 1200x630 pixels." +PLG_SYSTEM_OPENGRAPH_TWITTER_IMAGE_LABEL="Twitter Image" +PLG_SYSTEM_OPENGRAPH_TWITTER_TITLE_DESC="Custom title for Twitter sharing. Recommended length: 70 characters." +PLG_SYSTEM_OPENGRAPH_TWITTER_TITLE_HINT="Enter a compelling title for Twitter sharing" +PLG_SYSTEM_OPENGRAPH_TWITTER_TITLE_LABEL="Twitter Title" +PLG_SYSTEM_OPENGRAPH_TYPE_ARTICLE="Article" +PLG_SYSTEM_OPENGRAPH_TYPE_BLOG="Blog" +PLG_SYSTEM_OPENGRAPH_TYPE_BOOK="Book" +PLG_SYSTEM_OPENGRAPH_TYPE_EVENT="Event" +PLG_SYSTEM_OPENGRAPH_TYPE_FIELD_DESC="Select a custom field to use as the OpenGraph type source." +PLG_SYSTEM_OPENGRAPH_TYPE_FIELD_LABEL="Type Field" +PLG_SYSTEM_OPENGRAPH_TYPE_MUSIC="Music" +PLG_SYSTEM_OPENGRAPH_TYPE_PRODUCT="Product" +PLG_SYSTEM_OPENGRAPH_TYPE_PROFILE="Profile" +PLG_SYSTEM_OPENGRAPH_TYPE_VIDEO="Video" +PLG_SYSTEM_OPENGRAPH_TYPE_WEBSITE="Website" +PLG_SYSTEM_OPENGRAPH_USE_DEFAULT="Use Default" diff --git a/administrator/language/en-GB/plg_system_opengraph.sys.ini b/administrator/language/en-GB/plg_system_opengraph.sys.ini new file mode 100644 index 00000000000..dc51f59166d --- /dev/null +++ b/administrator/language/en-GB/plg_system_opengraph.sys.ini @@ -0,0 +1,12 @@ +; Joomla! Project +; (C) 2025 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_SYSTEM_OPENGRAPH="System - Open Graph" +PLG_SYSTEM_OPENGRAPH_DESCRIPTION="Automatic Open Graph tag generation plugin for Joomla 6.x with hierarchical metadata override system. Improves social media sharing with intelligent fallback mechanisms." +PLG_SYSTEM_OPENGRAPH_FIELDS_GROUP="Open Graph Metadata" +PLG_SYSTEM_OPENGRAPH_FIELDS_GROUP_DESC="Custom fields for Open Graph metadata configuration" +PLG_SYSTEM_OPENGRAPH_INSTALL_SUCCESS="Open Graph Plugin installed successfully!" +PLG_SYSTEM_OPENGRAPH_UNINSTALL_SUCCESS="Open Graph Plugin uninstalled successfully!" +PLG_SYSTEM_OPENGRAPH_UPDATE_SUCCESS="Open Graph Plugin updated successfully!" diff --git a/build/media_source/plg_system_opengraph/joomla.asset.json b/build/media_source/plg_system_opengraph/joomla.asset.json new file mode 100644 index 00000000000..05b49b6a9b8 --- /dev/null +++ b/build/media_source/plg_system_opengraph/joomla.asset.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json", + "name": "plg_system_opengraph", + "version": " __DEPLOY_VERSION__", + "description": "Joomla CMS", + "license": "GPL-2.0-or-later", + "assets": [ + { + "name": "plg_system_opengraph.opengraph-placeholder", + "type": "script", + "uri": "plg_system_opengraph/opengraph-placeholder.min.js", + "dependencies": [ + "core" + ], + "attributes": { + "type": "module" + } + } + ] +} diff --git a/build/media_source/plg_system_opengraph/js/opengraph-placeholder.es6.js b/build/media_source/plg_system_opengraph/js/opengraph-placeholder.es6.js new file mode 100644 index 00000000000..bc8e3bfe771 --- /dev/null +++ b/build/media_source/plg_system_opengraph/js/opengraph-placeholder.es6.js @@ -0,0 +1,124 @@ +/** + * @copyright (C) 2025 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + * @since __DEPLOY_VERSION__ + */ + +const initOpengraphPlaceholder = () => { + const maps = Joomla.getOptions("plgOgMappings", {}); + const limits = Joomla.getOptions("plgOgLimits", {}); + + const selectorFor = (token) => { + if (token.startsWith("field.")) { + const n = CSS.escape(token.slice(6)); + return `[name="jform[com_fields][${n}]"]`; + } + + if (token.startsWith("image_")) { + return `[name="jform[images][${CSS.escape(token)}]"]`; + } + + return `[name="jform[${CSS.escape(token)}]"]`; + }; + + const t = Joomla.Text._; + const mapCoreNames = { + title: t("JGLOBAL_TITLE"), + alias: t("JFIELD_ALIAS_LABEL"), + metadesc: t("JFIELD_META_DESCRIPTION_LABEL"), + metakey: t("JFIELD_META_KEYWORDS_LABEL"), + articletext: t("COM_CONTENT_FIELD_ARTICLETEXT_LABEL"), + image_intro: t("COM_CONTENT_FIELD_INTRO_LABEL"), + image_intro_alt: + t("COM_CONTENT_FIELD_INTRO_LABEL") + + " - " + + t("COM_CONTENT_FIELD_IMAGE_ALT_LABEL"), + image_fulltext: t("COM_CONTENT_FIELD_FULL_LABEL"), + image_fulltext_alt: + t("COM_CONTENT_FIELD_FULL_LABEL") + + " - " + + t("COM_CONTENT_FIELD_IMAGE_ALT_LABEL"), + created_by_alias: t("COM_CONTENT_FIELD_CREATED_BY_LABEL"), + }; + + const getSourceName = (token) => { + if (token.startsWith("field.")) { + const fieldKey = token.slice(6).replace(/_/g, " "); + return `Custom Field: ${fieldKey}`; + } + return mapCoreNames[token] || token; + }; + + const sanitizeText = (input, maxLen = 60) => { + if (typeof input !== "string" || !input.trim()) { + return ""; + } + + // Strip HTML tags + const noTags = input.replace(/<[^>]*>/g, " "); + + // Decode entities + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = noTags; + const decoded = tempDiv.textContent || tempDiv.innerText || ""; + + // Normalize whitespace + const cleaned = decoded.replace(/\s+/g, " ").trim(); + + // Safe truncate + if (cleaned.length <= maxLen) { + return cleaned; + } + + const cut = cleaned.lastIndexOf(" ", maxLen - 1); + const safeCut = cut > maxLen * 0.6 ? cut : maxLen - 1; + + return cleaned.slice(0, safeCut).replace(/[.,;:\-\s]+$/, "") + "…"; + }; + + const maxLen = { + og_title: Number(limits.maxTitleLength) || 60, + og_description: Number(limits.maxDescLength) || 160, + og_image_alt: Number(limits.maxAltLength) || 125, + }; + + Object.entries(maps).forEach(([ogKey, token]) => { + const ogInput = document.getElementById(`jform_attribs_${ogKey}`); + const srcInput = document.querySelector(selectorFor(token)); + + if (!ogInput || !srcInput) { + return; + } + + const originalPh = ogInput.placeholder; + const inherited = Joomla.Text._("PLG_SYSTEM_OPENGRAPH_INHERITED"); + + const paint = () => { + if (ogInput.value.trim()) { + return; + } // user override + + let v = srcInput.value.trim(); + if (ogKey !== "og_image") { + v = sanitizeText(v, maxLen[ogKey]); + } + + const sourceLabel = getSourceName(token); + ogInput.placeholder = v + ? `${v} — ${inherited} ${sourceLabel}` + : originalPh; + }; + + paint(); // initial render + srcInput.addEventListener("input", paint); + + ogInput.addEventListener("input", () => { + ogInput.placeholder = originalPh; // detach override + srcInput.removeEventListener("input", paint); + }); + }); +}; + +((document) => { + document.addEventListener("DOMContentLoaded", initOpengraphPlaceholder); +})(document); diff --git a/libraries/src/Opengraph/OpengraphServiceInterface.php b/libraries/src/Opengraph/OpengraphServiceInterface.php new file mode 100644 index 00000000000..3b30cd0fa6b --- /dev/null +++ b/libraries/src/Opengraph/OpengraphServiceInterface.php @@ -0,0 +1,42 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Opengraph; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + + +/** + * The Opengraph service. + * + * @since __DEPLOY_VERSION__ + */ +interface OpengraphServiceInterface +{ + /** + * Returns a grouped list of mappable fields used by the OpengraphField. + * + * @return array + * + * @since __DEPLOY_VERSION__ + * + */ + public function getOpengraphFields(): array; + + /** + * Returns the model name, based on the context + * + * @param string + * + * @return boolean + */ + public function getModelName($context): string; +} diff --git a/plugins/system/opengraph/opengraph.xml b/plugins/system/opengraph/opengraph.xml new file mode 100644 index 00000000000..65c4c24df1b --- /dev/null +++ b/plugins/system/opengraph/opengraph.xml @@ -0,0 +1,127 @@ + + + PLG_SYSTEM_OPENGRAPH + Joomla! Project + 2025-06 + (C) 2025 Open Source Matters, Inc. + GNU General Public License version 2 or later + support@joomla.org + https://joomla.org + __DEPLOY_VERSION__ + PLG_SYSTEM_OPENGRAPH_DESCRIPTION + Joomla\Plugin\System\Opengraph + + js + joomla.asset.json + + + src + services + opengraph.xml + + + language/en-GB/plg_system_opengraph.ini + language/en-GB/plg_system_opengraph.sys.ini + + + +
+ + + + + + + + + + +
+
+ + + + + + + + + + + +
+
+
+
diff --git a/plugins/system/opengraph/services/provider.php b/plugins/system/opengraph/services/provider.php new file mode 100644 index 00000000000..c48db300e29 --- /dev/null +++ b/plugins/system/opengraph/services/provider.php @@ -0,0 +1,44 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +\defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Plugin\System\Opengraph\Extension\Opengraph; + +return new class () implements ServiceProviderInterface { + /** + * Registers the service provider with a DI container. + * + * @param Container $container The DI container. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function register(Container $container) + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new Opengraph( + (array) PluginHelper::getPlugin('system', 'opengraph') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/plugins/system/opengraph/src/Extension/Opengraph.php b/plugins/system/opengraph/src/Extension/Opengraph.php new file mode 100644 index 00000000000..fb56a34930b --- /dev/null +++ b/plugins/system/opengraph/src/Extension/Opengraph.php @@ -0,0 +1,913 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Plugin\System\Opengraph\Extension; + +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Document\Document; +use Joomla\CMS\Document\HtmlDocument; +use Joomla\CMS\Event\Application\BeforeCompileHeadEvent; +use Joomla\CMS\Event\Model\PrepareFormEvent; +use Joomla\CMS\Filter\OutputFilter; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\CMS\Opengraph\OpengraphServiceInterface; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\Categories\Administrator\Model\CategoryModel; +use Joomla\Component\Content\Administrator\Model\ArticleModel; +use Joomla\Event\SubscriberInterface; +use Joomla\Registry\Registry; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * OpenGraph Metadata plugin. + * + * @since __DEPLOY_VERSION__ + */ + + +final class Opengraph extends CMSPlugin implements SubscriberInterface +{ + /** + * Should the plugin autoload its language files. + * + * @var bool + */ + protected $autoloadLanguage = true; + + /** + * Returns an array of events this subscriber will listen to. + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function getSubscribedEvents(): array + { + return [ + 'onBeforeCompileHead' => 'onBeforeCompileHead', + 'onContentPrepareForm' => 'onContentPrepareForm', + ]; + } + + + /** + * Add fields for the OpenGraph data to the form + * + * @param PrepareFormEvent $event + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function onContentPrepareForm(PrepareFormEvent $event): void + { + $form = $event->getForm(); + $context = $form->getName(); + if (!$this->getApplication()->isClient('administrator') || !$this->isSupported($context)) { + return; + } + + $isCategory = str_starts_with($context, 'com_categories.category'); + $isMenu = $context === 'com_menus.item'; + $parts = explode('.', $context, 2); + $componentName = $parts[0]; + $groupName = $isMenu ? 'params' : 'attribs'; + // Load opengraphmappings.xml for categories directly no need to adjust fields group + if ($isCategory) { + try { + $form::addFormPath(__DIR__ . '/../forms'); + $form->loadFile('opengraphmappings', false); + } catch (\Exception $e) { + error_log('OpenGraph Plugin: Failed to load mappings form: ' . $e->getMessage()); + } + + return; + } + // Load and modify opengraph.xml for articles and menus + $mainXml = __DIR__ . '/../forms/opengraph.xml'; + if (file_exists($mainXml)) { + try { + $modifiedXml = $this->adjustFieldsGroup($mainXml, $groupName); + $form->load($modifiedXml, false); + } catch (\Exception $e) { + error_log('OpenGraph Plugin: Failed to load main form: ' . $e->getMessage()); + } + } + // if the form is a menu item, we don't need to change placeholder values + if ($isMenu) { + return; + } + // Get the article id and category id + $input = $this->getApplication()->getInput(); + $itemId = (int) ($input->getInt('id') ?: $form->getValue('id') ?: 0); + $categoryId = (int) ($form->getValue('catid') ?: 0); + + if ($itemId > 0 && $categoryId === 0 && $componentName) { + try { + /** @var MVCComponent $component */ + $component = $this->getApplication()->bootComponent($componentName); + $modelName = null; + if ($component instanceof OpengraphServiceInterface) { + $modelName = $component->getModelName($context); + } + if (!$modelName) { + return; + } + /** @var MVCFactoryInterface $factory */ + $factory = $component->getMVCFactory(); + $model = $factory->createModel($modelName, 'Administrator', ['ignore_request' => true]); + if (method_exists($model, 'getItem')) { + $item = $model->getItem($itemId); + if (\is_object($item) && isset($item->catid)) { + $categoryId = (int) $item->catid; + } + } + } catch (\Exception $e) { + } + } + $catParams = new Registry(); + if ($categoryId > 0) { + /** @var MVCComponent $catComponent */ + $catComponent = $this->getApplication()->bootComponent('com_categories'); + /** @var MVCFactoryInterface $catFactory */ + $catFactory = $catComponent->getMVCFactory(); + + /** @var CategoryModel $catModel */ + $catModel = $catFactory->createModel('Category', 'Administrator', ['ignore_request' => true]); + $catModel->setState('category.id', $categoryId); + + $category = $catModel->getItem($categoryId); // JTable row + $catParams = new Registry($category->params ?? '{}'); + } + if (!$catParams) { + return; + } + // Get the mappings from the category params + $mappings = []; + foreach ($catParams as $paramKey => $fieldName) { + if (str_starts_with($paramKey, 'og_') && str_ends_with($paramKey, '_field')) { + $ogTag = substr($paramKey, 0, -6); // strip "_field" + $mappings[$ogTag] = $fieldName; + } + } + if (!$mappings) { + return; // category has no mappings + } + $maxTitleLength = $this->params->get('max_title_length', 60); + $maxDescLength = $this->params->get('max_description_length', 160); + $maxAltLength = $this->params->get('max_alt_length', 125); + $limits = [ + 'maxTitleLength' => $maxTitleLength, + 'maxDescLength' => $maxDescLength, + 'maxAltLength' => $maxAltLength, + ]; + + $mappings['twitter_title'] = $mappings['og_title'] ?? ''; + $mappings['twitter_description'] = $mappings['og_description'] ?? ''; + + $document = $this->getApplication()->getDocument(); + + $document->addScriptOptions('plgOgMappings', $mappings); + $document->addScriptOptions('plgOgLimits', $limits); + + Text::script('PLG_SYSTEM_OPENGRAPH_INHERITED'); + foreach ( + [ + 'JGLOBAL_TITLE', + 'JFIELD_ALIAS_LABEL', + 'JFIELD_META_DESCRIPTION_LABEL', + 'JFIELD_META_KEYWORDS_LABEL', + 'COM_CONTENT_FIELD_ARTICLETEXT_LABEL', + 'COM_CONTENT_FIELD_INTRO_LABEL', + 'COM_CONTENT_FIELD_IMAGE_ALT_LABEL', + 'COM_CONTENT_FIELD_FULL_LABEL', + 'COM_CONTENT_FIELD_CREATED_BY_LABEL', + ] as $key + ) { + Text::script($key); + } + + /** @var WebAssetManager $wa */ + $wa = $document->getWebAssetManager(); + + $wa->getRegistry()->addExtensionRegistryFile('plg_system_opengraph'); + $wa->useScript('plg_system_opengraph.opengraph-placeholder'); + } + + + /** + * Handle the beforeCompileHead event. + * + * @param BeforeCompileHeadEvent $event + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function onBeforeCompileHead(BeforeCompileHeadEvent $event): void + { + + $app = $event->getApplication(); + $document = $event->getDocument(); + + $input = $app->getInput(); + $option = $input->get('option'); + $view = $input->get('view'); + $context = $option . '.' . $view; + $id = $input->getInt('id'); + + + if (!$app->isClient('site') || !$this->isSupported($context)) { + return; + } + // Only process HTML documents + if (!($document instanceof HtmlDocument)) { + return; + } + // Plugin disabled? + if (!$this->params->get('enable_og_generation', 1)) { + return; + } + + $ogTags = $this->initializeOgTags(); + + if ($view === 'article' && $id > 0) { + $this->handleSingleItem($document, $ogTags, $id, $option, $view, $context); + return; + } + $this->handleMultipleArticleView($document, $ogTags, $option, $view, $id); + } + + + + + /** + * Handle Single Article + * Priority: Category Mappings → Article Form → Single Article Menu (if available) + * + * @param HtmlDocument $document + * @param array $ogTags + * @param int $id + * @param string $option + * @param string $view + * @param string $context + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function handleSingleItem(HtmlDocument $document, array $ogTags, int $id, string $option, string $view, string $context): void + { + $parts = explode('.', $context, 2); + $componentName = $parts[0]; + /** @var MVCComponent $component */ + $component = $this->getApplication()->bootComponent($componentName); + + $modelName = null; + if ($component instanceof OpengraphServiceInterface) { + $modelName = $component->getModelName($context); + } + if (!$modelName) { + return; + } + /** @var MVCFactoryInterface $mvcFactory */ + $mvcFactory = $component->getMVCFactory(); + + $params = ComponentHelper::getParams($componentName); + if (!$params instanceof Registry) { + $params = new Registry(); + } + + /** @var ArticleModel $model */ + $model = $mvcFactory->createModel($modelName, 'Site', ['ignore_request' => true]); + + $model->setState('params', clone $params); + $model->setState('article.id', $id); + + $article = $model->getItem($id); + if (!$article) { + return; + } + + /** @var CategoryModel $categoryModel */ + $categoryModel = $mvcFactory->createModel('Category', 'Site', ['ignore_request' => true]); + $categoryModel->setState('category.id', $article->catid); + $category = $categoryModel->getCategory(); + + $articleAttribs = new Registry($article->attribs ?? '{}'); + $categoryParams = new Registry($category->params ?? '{}'); + $articleImages = $this->getAllArticleImages(new Registry($article->images ?? '{}')); + + // Set article-specific properties + $ogTags['og_type'] = 'article'; + + // Step 1: Get OG tags from category mappings (Priority 1) + $this->getOgTagsFromCategoryMappings($categoryParams, $article, $articleImages, $ogTags); + + // Step 2: Get OG tags from article form (Priority 2) + $this->getOgTagsFromParams($articleAttribs, $ogTags); + + // Step 3: Check if there's a single article menu item and override (Priority 3 - Highest) + $singleArticleMenuParams = $this->getSingleArticleMenuParams($option, $view, $id); + $this->getOgTagsFromParams($singleArticleMenuParams, $ogTags); + + // Get default OG tags for any missing values + $this->getDefaultOgTags($ogTags); + + // Get Twitter tags + $this->getTwitterOgTags($ogTags); + + // Sanitize OG tags + $this->sanitizeOgTags($ogTags); + + // Inject the OpenGraph data into the document + $this->injectOpenGraphData($document, $ogTags); + } + + /** + * Handle Multiple Article Views (category, blog) + * Only check menu form + * + * @param HtmlDocument $document + * @param array $ogTags + * @param string $option + * @param string $view + * @param int|null $categoryId + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function handleMultipleArticleView(HtmlDocument $document, array $ogTags, string $option, string $view, int|null $categoryId): void + { + + + $menuParams = $this->getMultipleArticleMenuParams($option, $view, $categoryId); + + + if (!$menuParams->count()) { + return; + } + + // Apply menu parameters only + $this->getOgTagsFromParams($menuParams, $ogTags); + + // Get Twitter tags + $this->getTwitterOgTags($ogTags); + + // Sanitize OG tags + $this->sanitizeOgTags($ogTags); + + // Inject the OpenGraph data into the document + $this->injectOpenGraphData($document, $ogTags); + } + + /** + * Get menu parameters only for single article menu items + * + * @param string $option + * @param string $view + * @param int $id + * + * @return Registry + * + * @since __DEPLOY_VERSION__ + */ + private function getSingleArticleMenuParams(string $option, string $view, int $id): Registry + { + $menu = $this->getApplication()->getMenu(); + $active = $menu->getActive(); + + // Default empty params + $params = new Registry(); + + if (!$active || !isset($active->query['option']) || $active->query['option'] !== $option) { + return $params; + } + + // Only return params if this is a direct single article menu item + if ( + isset($active->query['view']) && $active->query['view'] === 'article' && + isset($active->query['id']) && (int) $active->query['id'] === $id + ) { + return $active->getParams(); + } + + return $params; + } + + /** + * Get menu parameters for multiple article views (category, blog, featured) + * + * @param string $option + * @param string $view + * @param int|null $categoryId + * + * @return Registry + * + * @since __DEPLOY_VERSION__ + */ + private function getMultipleArticleMenuParams(string $option, string $view, ?int $categoryId = null): Registry + { + $menu = $this->getApplication()->getMenu(); + $active = $menu->getActive(); + + // Default empty params + $params = new Registry(); + + if (!$active || !isset($active->query['option']) || $active->query['option'] !== $option) { + return $params; + } + + // Check for direct menu match based on view type + if (isset($active->query['view']) && $active->query['view'] === $view) { + // For category-based views with an ID parameter + if (\in_array($view, ['category', 'categoryblog'], true) && isset($active->query['id'])) { + if ($categoryId !== null && (int) $active->query['id'] === (int) $categoryId) { + return $active->getParams(); + } + } elseif ($view === 'featured') { + return $active->getParams(); + } else { + return $active->getParams(); + } + } + + return $params; + } + + + /** + * Initialize OG tags array + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function initializeOgTags(): array + { + $config = $this->getApplication()->getConfig(); + + return [ + 'og_title' => '', + 'og_description' => '', + 'og_image' => '', + 'og_image_alt' => '', + 'og_type' => 'website', + 'og_url' => Uri::getInstance()->toString(), + 'twitter_card' => 'summary', + 'twitter_title' => '', + 'twitter_description' => '', + 'twitter_image' => '', + 'twitter_image_alt' => '', + 'fb_app_id' => $this->params->get('fb_app_id', ''), + 'site_name' => $config->get('sitename'), + 'url' => Uri::getInstance()->toString(), + 'base_url' => Uri::base(), + ]; + } + + /** + * Generates OG metadata values based on category field mapping and article data. + * + * @param Registry $categoryParams The category params containing OG field mappings. + * @param object $article The article object. + * @param array $articleImages The array of article images. + * @param array $ogTags The array of OG tags. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function getOgTagsFromCategoryMappings(Registry $categoryParams, object $article, array $articleImages, array &$ogTags): void + { + + foreach ($categoryParams as $key => $fieldName) { + // Only process keys that start with 'og_' + if (strpos($key, 'og_') === 0 && str_ends_with($key, '_field')) { + $ogTagName = substr($key, 0, -6); // Remove "_field" from the end + + $value = $this->getFieldValue($article, $fieldName, $articleImages); + $ogTags[$ogTagName] = $value; + } + } + } + + /** + * Get value from article field or custom field + * + * @param Content $article + * @param string $fieldName + * @param array $articleImages + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function getFieldValue(object $article, string $fieldName, array $articleImages): string + { + $value = ''; + + switch ($fieldName) { + case 'title': + $value = $article->title; + break; + + case 'alias': + $value = $article->alias; + break; + + case 'articletext': + $value = $article->introtext . $article->fulltext; + break; + + case 'metadesc': + $value = $article->metadesc; + break; + + case 'metakey': + $value = $article->metakey; + break; + + case 'image_intro': + $value = $articleImages['image_intro']; + break; + + case 'image_fulltext': + $value = $articleImages['image_fulltext']; + break; + + case 'image_intro_alt': + $value = $articleImages['image_intro_alt']; + break; + + case 'image_fulltext_alt': + $value = $articleImages['image_fulltext_alt']; + break; + + case 'created_by_alias': + $value = $article->created_by_alias; + break; + + default: + if (property_exists($article, $fieldName)) { + $value = $article->$fieldName; + } + } + + return (string) $value; + } + + /** + * @param Registry $articleImages + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + private function getAllArticleImages(Registry $articleImages): array + { + $image_intro = $image_intro_alt = ''; + $image_fulltext = $image_fulltext_alt = ''; + + // Handle image_intro + if ($articleImages->get('image_intro') > '') { + // get part before # in image if # exists + $image_intro = strpos($articleImages->get('image_intro'), '#') !== false + ? substr($articleImages->get('image_intro'), 0, strpos($articleImages->get('image_intro'), '#')) + : $articleImages->get('image_intro'); + + $image_intro_alt = $articleImages->get('image_intro_alt', ''); + } + + // Handle image_fulltext + if ($articleImages->get('image_fulltext') > '') { + $image_fulltext = strpos($articleImages->get('image_fulltext'), '#') !== false + ? substr($articleImages->get('image_fulltext'), 0, strpos($articleImages->get('image_fulltext'), '#')) + : $articleImages->get('image_fulltext'); + + $image_fulltext_alt = $articleImages->get('image_fulltext_alt', ''); + } + + return [ + 'image_intro' => $image_intro, + 'image_intro_alt' => $image_intro_alt, + 'image_fulltext' => $image_fulltext, + 'image_fulltext_alt' => $image_fulltext_alt, + ]; + } + + /** + * Extract OG tags from a parameter source (article, menu) + * + * @param Registry $params The source of OG field mappings (e.g. article attribs, menu params) + * @param array &$ogTags Reference to the OG tags array to populate + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function getOgTagsFromParams(Registry $params, array &$ogTags): void + { + + foreach (array_keys($ogTags) as $ogTagName) { + if ($params->exists($ogTagName)) { + $value = $params->get($ogTagName); + + + if ($value) { + $ogTags[$ogTagName] = $value; + } + } + } + } + + /** + * Get Global Default OG tags if not till not set + * @param array &$ogTags + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function getDefaultOgTags(array &$ogTags): void + { + // Get Global Default OG tags if not set + $defaultOgTags = [ + 'og_title' => $this->params->get('default_og_title'), + 'og_description' => $this->params->get('default_og_description'), + 'og_image' => $this->params->get('default_og_image'), + 'og_image_alt' => $this->params->get('default_og_image_alt'), + 'site_name' => $this->params->get('default_og_site_name'), + 'fb_app_id' => $this->params->get('fb_app_id'), + ]; + + foreach ($defaultOgTags as $key => $value) { + if ($ogTags[$key] === '') { + $ogTags[$key] = $value; + } + } + } + + /** + * Get Twitter tags if not set use OG value + * @param array &$ogTags + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function getTwitterOgTags(array &$ogTags): void + { + $twitterTags = [ + 'twitter_title' => $ogTags['twitter_title'], + 'twitter_description' => $ogTags['twitter_description'], + 'twitter_image' => $ogTags['twitter_image'], + 'twitter_image_alt' => $ogTags['twitter_image_alt'], + ]; + foreach ($twitterTags as $key => $value) { + // If the value is not set, use the OG value + if (!$value || $value === '') { + $ogTags[$key] = $ogTags['og_' . substr($key, 8)]; + } + } + } + + /** + * Inject the OpenGraph data into the document. + * + * @param Document $document + * @param array $ogTags + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function injectOpenGraphData(Document $document, array $ogTags): void + { + // OpenGraph tags + $this->setMetaData($document, 'og:title', $ogTags['og_title'], 'property'); + $this->setMetaData($document, 'og:description', $ogTags['og_description'], 'property'); + $this->setMetaData($document, 'og:type', $ogTags['og_type'], 'property'); + $this->setMetaData($document, 'og:url', $ogTags['og_url'], 'property'); + + // Twitter tags + $this->setMetaData($document, 'twitter:card', $ogTags['twitter_card'], 'name'); + $this->setMetaData($document, 'twitter:title', $ogTags['twitter_title'], 'name'); + $this->setMetaData($document, 'twitter:description', $ogTags['twitter_description'], 'name'); + + // Facebook App ID + $this->setMetaData($document, 'fb:app_id', $ogTags['fb_app_id'], 'property'); + + $this->setMetaData($document, 'og:site_name', $ogTags['site_name'], 'property'); + + $this->setOpenGraphImage($document, $ogTags); + } + + /** + * Set metadata tag in document. + * + * @param Document $document + * @param string $name + * @param string|null $value + * @param string $attributeType + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function setMetaData(Document $document, string $name, ?string $value, string $attributeType): void + { + if (!empty($value)) { + $document->setMetaData($name, $value, $attributeType); + } + } + + /** + * Add OpenGraph image to document + * + * @param Document $document + * @param array $ogTags + * + * @return void + * @since __DEPLOY_VERSION__ + */ + private function setOpenGraphImage( + Document $document, + array $ogTags + ): void { + $image = $ogTags['og_image']; + $alt = $ogTags['og_image_alt']; + $baseUrl = $ogTags['base_url']; + $twitterImage = $ogTags['twitter_image']; + $twitterImageAlt = $ogTags['twitter_image_alt']; + + if (empty($image)) { + return; + } + + $image = preg_replace('~^([\w\-./\\\]+).*$~', '$1', $image); + + if (!file_exists($image)) { + return; + } + + $ogImageUrl = empty($baseUrl) ? '' : rtrim($baseUrl, '/') . '/'; + $ogImageUrl .= $image; + + $twitterImageUrl = empty($baseUrl) ? '' : rtrim($baseUrl, '/') . '/'; + $twitterImageUrl .= $twitterImage; + + if (empty($document->getMetaData('og:image'))) { + $this->setMetaData($document, 'og:image', $ogImageUrl, 'property'); + } + + $this->setMetaData($document, 'og:image:alt', $alt, 'property'); + $this->setMetaData($document, 'og:image:secure_url', $ogImageUrl, 'property'); + $this->setMetaData($document, 'twitter:image', $twitterImageUrl, 'name'); + $this->setMetaData($document, 'twitter:image:alt', $twitterImageAlt, 'name'); + + $info = getimagesize($image); + + if (\is_array($info)) { + $this->setMetaData($document, 'og:image:type', $info['mime'], 'property'); + $this->setMetaData($document, 'og:image:height', $info[1], 'property'); + $this->setMetaData($document, 'og:image:width', $info[0], 'property'); + } + } + + + /** + * Check if the current plugin should execute opengraph related activities + * + * @param string $context + * + * @return boolean + * + * @since __DEPLOY_VERSION__ + */ + protected function isSupported($context): bool + { + $parts = explode('.', $context, 2); + if (empty($parts)) { + return false; + } + + if ($parts[0] === 'com_categories' && isset($parts[1])) { + // Extract true component name from com_categories context + if (preg_match('/com_[a-zA-Z0-9_]+$/', $parts[1], $matches)) { + $componentName = $matches[0]; + } else { + return false; + } + } else { + $componentName = $parts[0]; + } + + try { + $component = $this->getApplication()->bootComponent($componentName); + } catch (\Exception $e) { + error_log('OpenGraph Plugin: Failed to boot component: ' . $e->getMessage()); + return false; + } + + return $component instanceof OpengraphServiceInterface; + } + + /** + * Adjust the fields group in the XML file + * + * @param string $filePath + * @param string $newGroup + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function adjustFieldsGroup(string $filePath, string $newGroup): string + { + $xmlContent = file_get_contents($filePath); + $xml = simplexml_load_string($xmlContent); + + if ($xml === false) { + throw new \Exception("Could not load XML file: {$filePath}"); + } + + // Adjust all nodes to use the desired group + foreach ($xml->xpath('//fields') as $fields) { + $fields['name'] = $newGroup; + } + + return $xml->asXML(); + } + + /** + * Clean up and normalise all OG / Twitter tag values. + * + * @param array &$ogTags Reference to the tag array created earlier. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function sanitizeOgTags(array &$ogTags): void + { + // TODO : will be configurable in the future from the plugin settings + + $maxTitleLen = $this->params->get('max_title_length', 60); // Facebook shows ~55–60 chars, Twitter ~70 + $maxDescLen = $this->params->get('max_description_length', 160); // Twitter summary cards truncate after ~160 + $maxAltLen = $this->params->get('max_alt_length', 125); // WCAG recommendation for alt text + + foreach (['og_title', 'twitter_title'] as $key) { + $ogTags[$key] = $this->cleanText($ogTags[$key] ?? '', $maxTitleLen); + } + + foreach (['og_description', 'twitter_description'] as $key) { + $ogTags[$key] = $this->cleanText($ogTags[$key] ?? '', $maxDescLen); + } + + foreach (['og_image_alt', 'twitter_image_alt'] as $key) { + $ogTags[$key] = $this->cleanText($ogTags[$key] ?? '', $maxAltLen); + } + + // Make sure og:url is absolute + if (!empty($ogTags['og_url']) && !preg_match('~^https?://~i', $ogTags['og_url'])) { + $ogTags['og_url'] = Uri::root() . ltrim($ogTags['og_url'], '/'); + } + } + + /** + * Helper: strip HTML, decode entities, collapse whitespace, then truncate on a word boundary and add an ellipsis if needed. + * + * @param string $text + * @param int $maxLen + * + * @return string + * + * @since __DEPLOY_VERSION__ + */ + private function cleanText(string $text, int $maxLen): string + { + // Remove tags and entities, normalise whitespace + $plain = OutputFilter::cleanText($text); + + // Truncate the text on a word boundary and add an ellipsis if needed + $truncated = HTMLHelper::_('string.truncate', $plain, $maxLen, true, false); + + // Replace the three-dot ellipsis with a single Unicode one + return preg_replace('/\.\.\.$/', '…', $truncated); + } +} diff --git a/plugins/system/opengraph/src/Field/OpengraphField.php b/plugins/system/opengraph/src/Field/OpengraphField.php new file mode 100644 index 00000000000..846564f1d81 --- /dev/null +++ b/plugins/system/opengraph/src/Field/OpengraphField.php @@ -0,0 +1,102 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Plugin\System\Opengraph\Field; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\Field\GroupedlistField; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Opengraph\OpengraphServiceInterface; + +// phpcs:disable PSR1.Files.SideEffects +\defined('_JEXEC') or die; +// phpcs:enable PSR1.Files.SideEffects + +/** + * Form Field class for the Joomla Platform. + * Supports a generic list of options. + * + * @since __DEPLOY_VERSION__ + */ + +class OpengraphField extends GroupedlistField +{ + /** + * The form field type. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $type = 'Opengraph'; + + + /** + * Method to get the field options. + * + * @return object[] The field option objects. + * + * @since __DEPLOY_VERSION__ + */ + protected function getGroups() + { + $app = Factory::getApplication(); + $groups = []; + + $groups[''] = [ + HTMLHelper::_('select.option', '', Text::_('PLG_SYSTEM_OPENGRAPH_NO_FIELD_SELECTED')), + ]; + + + $componentName = ''; + if ($this->form) { + $componentName = (string) ($this->form->getValue('extension') + ?: $this->form->getData()->get('extension')); + } + if (!$componentName) { + $context = (string) ($this->form ? $this->form->getName() : ''); + $componentName = $context ? explode('.', $context, 2)[0] ?? '' : ''; + if (!$componentName) { + $componentName = (string) $app->input->getCmd('option', ''); + } + } + if (!$componentName) { + return $groups; + } + + try { + $component = $app->bootComponent($componentName); + } catch (\Throwable $e) { + return $groups; + } + + if (!$component instanceof OpengraphServiceInterface) { + return $groups; + } + + + $ogOptions = []; + + $fields = $component->getOpengraphFields(); + $fieldType = $this->getAttribute('field-type'); + + if (isset($fields[$fieldType])) { + foreach ($fields[$fieldType] as $value => $text) { + $ogOptions[] = HTMLHelper::_('select.option', $value, $text); + } + } + + if (!empty($ogOptions)) { + $groups[Text::_('PLG_SYSTEM_OPENGRAPH_GROUP_DEFAULT_FIELDS')] = $ogOptions; + } + + + return $groups; + } +} diff --git a/plugins/system/opengraph/src/forms/opengraph.xml b/plugins/system/opengraph/src/forms/opengraph.xml new file mode 100644 index 00000000000..be8de280f09 --- /dev/null +++ b/plugins/system/opengraph/src/forms/opengraph.xml @@ -0,0 +1,147 @@ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/plugins/system/opengraph/src/forms/opengraphmappings.xml b/plugins/system/opengraph/src/forms/opengraphmappings.xml new file mode 100644 index 00000000000..abf543e3200 --- /dev/null +++ b/plugins/system/opengraph/src/forms/opengraphmappings.xml @@ -0,0 +1,59 @@ + +
+ +
+
+ + + + + + + + + + + + + + + +
+
+
+