diff --git a/administrator/language/en-GB/plg_system_schemaorg.ini b/administrator/language/en-GB/plg_system_schemaorg.ini index 4e13d9795cd..063a4649fa3 100644 --- a/administrator/language/en-GB/plg_system_schemaorg.ini +++ b/administrator/language/en-GB/plg_system_schemaorg.ini @@ -8,6 +8,7 @@ PLG_SYSTEM_SCHEMAORG_BASETYPE_DESCRIPTION="Choose whether your website represent PLG_SYSTEM_SCHEMAORG_BASETYPE_LABEL="Base Type" PLG_SYSTEM_SCHEMAORG_BASETYPE_OPTION_ORGANIZATION="Organization" PLG_SYSTEM_SCHEMAORG_BASETYPE_OPTION_PERSON="Person" +PLG_SYSTEM_SCHEMAORG_CONTACT_LABEL="Select Contact" PLG_SYSTEM_SCHEMAORG_FIELD_SCHEMA_DESCRIPTION="Structured data is a standardised format for organising and representing information on the web. It provides a way to describe the content and meaning of data in a structured manner, making it easier for search engines and other applications to understand and process the information. More information on schema.org." PLG_SYSTEM_SCHEMAORG_FIELD_SCHEMA_DESCRIPTION_NOT_CONFIGURED="To use the schema.org functionality, you have to configure the plugin first. Please contact an administrator of the page to get it configured." PLG_SYSTEM_SCHEMAORG_FIELD_SCHEMA_DESCRIPTION_NOT_CONFIGURED_ADMIN="To use the schema.org functionality, you have to configure the plugin first. Please select this link to open the plugin, configure and save." @@ -15,6 +16,8 @@ PLG_SYSTEM_SCHEMAORG_FIELD_SCHEMA_EXTEND_JED_DESC="Need more Schema type PLG_SYSTEM_SCHEMAORG_FIELD_SCHEMA_LABEL="Schema" PLG_SYSTEM_SCHEMAORG_FIELD_SCHEMA_TYPE_LABEL="Schema Type" PLG_SYSTEM_SCHEMAORG_IMAGE_LABEL="Image" +PLG_SYSTEM_SCHEMAORG_INHERIT_CONTACT="Inherited from contact" +PLG_SYSTEM_SCHEMAORG_INHERIT_DEFAULT_CONTACT="Inherited from default contact" PLG_SYSTEM_SCHEMAORG_NAME_LABEL="Name" PLG_SYSTEM_SCHEMAORG_SOCIALMEDIA_LABEL="Social Media Accounts" PLG_SYSTEM_SCHEMAORG_SOCIALMEDIA_URL_LABEL="URL" diff --git a/build/media_source/plg_system_schemaorg/joomla.asset.json b/build/media_source/plg_system_schemaorg/joomla.asset.json new file mode 100644 index 00000000000..8b3d56d01a3 --- /dev/null +++ b/build/media_source/plg_system_schemaorg/joomla.asset.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json", + "name": "plg_system_schemaorg", + "version": "__DEPLOY_VERSION__", + "description": "Joomla CMS", + "license": "GPL-2.0-or-later", + "assets": [ + { + "name": "plg_system_schemaorg.contact", + "type": "script", + "uri": "plg_system_schemaorg/schemaorg-contact.js", + "dependencies": [ + "core" + ], + "attributes": { + "type": "module" + } + } + ] +} diff --git a/build/media_source/plg_system_schemaorg/js/schemaorg-contact.es6.js b/build/media_source/plg_system_schemaorg/js/schemaorg-contact.es6.js new file mode 100644 index 00000000000..521fe914444 --- /dev/null +++ b/build/media_source/plg_system_schemaorg/js/schemaorg-contact.es6.js @@ -0,0 +1,195 @@ +/** + * @copyright (C) 2025 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ +/** + * schemaorg-contact.js + * Populate placeholders from injected initialContact by baseName. + */ +((Joomla, document) => { + "use strict"; + + class SchemaOrgContactHandler { + constructor(options) { + this.options = Object.assign( + { + fieldMappings: { + name: "name", + email: "email_to", + url: "webpage", + streetAddress: ["address", "street"], + addressLocality: ["suburb", "state", "address"], + postalCode: "postcode", + addressRegion: "state", + addressCountry: "country", + telephone: "telephone", + mobile: "mobile", + fax: "fax", + misc: "misc", + position: "position", + }, + }, + options + ); + + // nested fields that must go under [address] + this.nestedFields = { + streetAddress: "address", + postalCode: "address", + addressLocality: "address", + addressRegion: "address", + addressCountry: "address", + }; + + // read injected contact from Joomla options or global + const opt = + (Joomla && + Joomla.getOptions && + Joomla.getOptions("plg_system_schemaorg")) || + window.plg_system_schemaorg || + {}; + this.initialContact = opt.initialContact || null; + + this._initOnce = false; + this.init(); + } + + init() { + if (this._initOnce) return; + this._initOnce = true; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => this.bindEvents()); + } else { + this.bindEvents(); + } + } + + bindEvents() { + // Scope to the current form + const form = document.querySelector("form"); + if (!form) return; + + // Contact title inputs in the form + const contactNameFields = form.querySelectorAll( + "input.js-input-title[name*='[contact]']" + ); + let isPopulated = false; + contactNameFields.forEach((field) => { + if (field.value && field.value.trim() && this.initialContact) { + this.populatePlaceholders(field, this.initialContact); + isPopulated = true; + } + }); + if (!isPopulated) { + contactNameFields.forEach((field) => { + if (this.initialContact) { + this.populatePlaceholders(field, this.initialContact); + isPopulated = true; + } + }); + } + } + + populatePlaceholders(contactField, contactData) { + if (!contactField || !contactData) return; + + // Normalize simple contact object and trim strings + const contact = Object.assign({}, contactData); + let isDefaultContact = false; + Object.keys(contact).forEach((k) => { + if (typeof contact[k] === "string") contact[k] = contact[k].trim(); + if (k === "isDefaultContact") { + isDefaultContact = Boolean(contact[k]); + } + }); + + // find the role container (use nearby grouping) + const roleContainer = contactField.closest( + ".subform-repeatable-group, fieldset, form" + ); + if (!roleContainer) return; + + // Parse baseName from contact field name + const contactName = contactField.name || ""; + const baseMatch = contactName.match(/^(.*)\[contact\]$/); + if (!baseMatch) { + console.debug("schemaorg: contact baseName not found for", contactName); + return; + } + const baseName = baseMatch[1]; // e.g. jform[schema][Book][illustrator] + + // For each mapping, compute target name and set placeholder + Object.entries(this.options.fieldMappings).forEach( + ([schemaField, contactKey]) => { + let value = ""; + + if (Array.isArray(contactKey)) { + for (const k of contactKey) { + if (contact[k]) { + value = contact[k]; + break; + } + } + } else { + value = contact[contactKey] || ""; + } + + if (!value) return; + const parentKey = this.nestedFields[schemaField] || null; + const targetName = parentKey + ? `${baseName}[${parentKey}][${schemaField}]` + : `${baseName}[${schemaField}]`; + + // find the input within the same roleContainer + const selector = `input[name='${targetName}'], textarea[name='${targetName}'], select[name='${targetName}']`; + const targetField = + roleContainer.querySelector(selector) || + document.querySelector(selector); + let newValue = value; + const defaultContactText = Joomla.Text._( + "PLG_SYSTEM_SCHEMAORG_INHERIT_DEFAULT_CONTACT" + ); + + const contactText = Joomla.Text._( + "PLG_SYSTEM_SCHEMAORG_INHERIT_CONTACT" + ); + + if (isDefaultContact) { + newValue += ` — ${defaultContactText} "${contact.name}"`; + } else { + newValue += ` — ${contactText} "${contact.name}"`; + } + if (targetField) { + targetField.placeholder = newValue; + targetField.classList.add("has-contact-placeholder"); + } else { + // debug if not found + console.debug( + "schemaorg: could not find target field for", + targetName + ); + } + } + ); + + // emit event + document.dispatchEvent( + new CustomEvent("schemaorg:contact:loaded", { + detail: { + contactId: contact.id, + contactData: contact, + container: roleContainer, + }, + }) + ); + } + } + + // Initialize once + if (typeof Joomla !== "undefined") { + if (!Joomla.SchemaOrgContactHandler) { + Joomla.SchemaOrgContactHandler = new SchemaOrgContactHandler(); + } + } +})(window.Joomla, document); diff --git a/plugins/system/schemaorg/schemaorg.xml b/plugins/system/schemaorg/schemaorg.xml index 326ef254756..b81bd4e2e9e 100644 --- a/plugins/system/schemaorg/schemaorg.xml +++ b/plugins/system/schemaorg/schemaorg.xml @@ -20,7 +20,7 @@ -
+
+ + [ + 'author', + ], + 'BlogPosting' => [ + 'author', + ], + 'Book' => [ + 'illustrator', + ], + 'Event' => [ + 'organizer', + ], + + ]; + + /** +     * Temporarily holds schema data between onContentPrepareData and onContentPrepareForm events. +     * +     * @var  ?array +     * @since __DEPLOY_VERSION__ +     */ + private ?array $preparedSchemaData = null; /** * Returns an array of events this subscriber will listen to. * @@ -117,6 +147,8 @@ public function onContentPrepareData(Model\PrepareDataEvent $event) $schema = new Registry($results['schema']); $data->schema[$schemaType] = $schema->toArray(); + // Store the loaded data for use in onContentPrepareForm + $this->preparedSchemaData = $data->schema; } $dispatcher = $this->getDispatcher(); @@ -152,7 +184,6 @@ public function onContentPrepareForm(Model\PrepareFormEvent $event) // Load the form fields $form->loadFile(JPATH_PLUGINS . '/' . $this->_type . '/' . $this->_name . '/forms/schemaorg.xml'); - // The user should configure the plugin first if (!$this->params->get('baseType')) { $form->removeField('schemaType', 'schema'); @@ -183,8 +214,356 @@ public function onContentPrepareForm(Model\PrepareFormEvent $event) PluginHelper::importPlugin('schemaorg', null, true, $dispatcher); $dispatcher->dispatch('onSchemaPrepareForm', $event); + + // Inject contact fields into relevant roles + foreach (self::ROLE_CONTACT_MAP as $type => $roles) { + foreach ($roles as $role) { + $this->injectContactField($form, $type, $role); + } + } + + // After injecting contact fields, load the JavaScript + if ($app->isClient('administrator') && $this->isSupported($context)) { + $contactId = 0; + if ($this->preparedSchemaData) { + foreach (self::ROLE_CONTACT_MAP as $type => $roles) { + if (isset($this->preparedSchemaData[$type])) { + foreach ($roles as $role) { + if (!empty($this->preparedSchemaData[$type][$role]['contact'])) { + $contactId = (int) $this->preparedSchemaData[$type][$role]['contact']; + break 2; + } + } + } + } + } + + $this->loadContactFieldAssets($contactId); + } + } + + /** +  * Load JavaScript and CSS assets for contact field functionality +     * and pass pre-loaded contact data if it exists. +     * +     * @param   int  $contactId  The ID of a pre-selected contact, or 0 if none. +     * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function loadContactFieldAssets(int $contactId = 0): void + { + + $app = $this->getApplication(); + $doc = $app->getDocument(); + + if (!($doc instanceof \Joomla\CMS\Document\HtmlDocument)) { + return; + } + $defaultContactId = (int) $this->params->get('defaultContact', 0); + $isDefaultContact = false; + if ($contactId <= 0 && $defaultContactId > 0) { + $contactId = $defaultContactId; + $isDefaultContact = true; + } + $initialContactData = null; + if ($contactId > 0) { + + /** @var \Joomla\CMS\Extension\MVCComponent $component */ + $component = $app->bootComponent('com_contact'); + $mvcFactory = $component->getMVCFactory(); + + /** @var ContactModel $contactModel */ + $contactModel = $mvcFactory->createModel('Contact', 'Administrator', ['ignore_request' => true]); + $contact = $contactModel->getItem($contactId); + if ($contact) { + $initialContactData = [ + 'id' => (int) ($contact->id ?? 0), + 'name' => $contact->name ?? '', + 'email_to' => $contact->email_to ?? '', + 'address' => $contact->address ?? '', + 'street' => $contact->street ?? '', + 'suburb' => $contact->suburb ?? '', + 'state' => $contact->state ?? '', + 'postcode' => $contact->postcode ?? '', + 'country' => $contact->country ?? '', + 'telephone' => $contact->telephone ?? '', + 'webpage' => $contact->webpage ?? '', + 'isDefaultContact' => $isDefaultContact, + ]; + } + } + $wa = $doc->getWebAssetManager(); + + // Register the asset if not already registered + if (!$wa->assetExists('script', 'plg_system_schemaorg.contact')) { + $wa->registerScript( + 'plg_system_schemaorg.contact', + 'plg_system_schemaorg/schemaorg-contact.js', + ['version' => 'auto', 'relative' => true], + ['defer' => true], + ['core'] + ); + } + + // Load language strings + Text::script('PLG_SYSTEM_SCHEMAORG_INHERIT_DEFAULT_CONTACT'); + Text::script('PLG_SYSTEM_SCHEMAORG_INHERIT_CONTACT'); + + // Use the assets + $wa->useScript('plg_system_schemaorg.contact'); + + // Add inline configuration + $doc->addScriptOptions('plg_system_schemaorg', [ + 'initialContact' => $initialContactData, + ]); } + /** + * Inject a Contact selector field into a role subform for a given Schema.org type. + * + * @param \Joomla\CMS\Form\Form $form The active form being prepared. + * @param string $type Schema.org type name (e.g. "Article", "Book"). + * @param string $role Role field name under that type (e.g. "author", "illustrator"). + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function injectContactField(\Joomla\CMS\Form\Form $form, string $type, string $role): void + { + + $xml = $form->getXml(); + $nodes = $xml->xpath("//fieldset[@name='schema']/field[@name='{$type}']/form/field[@name='{$role}']"); + if (!$nodes || !isset($nodes[0])) { + return; // no such role in this type + } + + $roleField = $nodes[0]; + + // Get current user and permission checks for com_contact + $user = $this->getApplication()->getIdentity(); + $canCreate = $user->authorise('core.create', 'com_contact'); + $canEdit = $user->authorise('core.edit', 'com_contact'); + $canView = $user->authorise('core.view', 'com_contact'); + + $contact = new \SimpleXMLElement(''); + $contact->addAttribute('name', 'contact'); + $contact->addAttribute('type', 'modal_contact'); + $contact->addAttribute('label', 'COM_CONTACT_SELECT_CONTACT_LABEL'); + $contact->addAttribute('hiddenLabel', 'true'); + + // Only show select if user can view contacts + if ($canView) { + $contact->addAttribute('select', 'true'); + } + + // Only allow creating a contact if user has create permission + if ($canCreate) { + $contact->addAttribute('new', 'true'); + } + + // Edit button shown only to users with edit permission + if ($canEdit) { + $contact->addAttribute('edit', 'true'); + } + + // Clear should be allowed if user can view (so they can clear their selection) + if ($canView) { + $contact->addAttribute('clear', 'true'); + } + + $contact->addAttribute('addfieldprefix', 'Joomla\Component\Contact\Administrator\Field'); + + // Prepend into the role’s inner
so it’s the first control + $domForm = dom_import_simplexml($roleField->form); + $domContact = $domForm->ownerDocument->importNode(dom_import_simplexml($contact), true); + $domForm->insertBefore($domContact, $domForm->firstChild); + } + + /** + * Enrich top-level @graph entries for configured types/roles using com_contact data. + * + * @param array $graph + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function enrichGraphContacts(array &$graph): void + { + + + foreach ($graph as &$entry) { + if (!\is_array($entry) || empty($entry['@type'])) { + continue; + } + + $type = $entry['@type']; + + if (!isset(self::ROLE_CONTACT_MAP[$type])) { + continue; + } + + foreach (self::ROLE_CONTACT_MAP[$type] as $role) { + if (!isset($entry[$role])) { + continue; + } + + // Normalize to list of role nodes by reference + $roleNodes = []; + $roleNodes = [&$entry[$role]]; + + + // Enrich each role node when it has a contact id + $filledNodes = 0; + foreach ($roleNodes as &$roleNode) { + if (!isset($roleNode['contact']) || (int) $roleNode['contact'] <= 0) { + continue; + } + $contact = $this->getContactById((int) $roleNode['contact']); + if ($contact) { + $this->fillNodeFromContact($roleNode, $contact); + $filledNodes++; + unset($roleNode['contact']); + } + } + + if ($filledNodes == 0) { + $defaultContactId = (int) $this->params->get('defaultContact', 0); + if ($defaultContactId > 0) { + $contact = $this->getContactById($defaultContactId); + if ($contact) { + $this->fillNodeFromContact($roleNode, $contact); + } + } + } + } + } + } + + + + /** + * Fetch a com_contact record by its ID. + * + * @param int $id + * + * @return stdClass|null + * + * @since __DEPLOY_VERSION__ + */ + private function getContactById(int $id) + { + try { + $app = $this->getApplication(); + + /** @var \Joomla\CMS\Extension\MVCComponent $component */ + $component = $app->bootComponent('com_contact'); + $mvcFactory = $component->getMVCFactory(); + + /** @var \Joomla\Component\Contact\Site\Model\ContactModel $model */ + $model = $mvcFactory->createModel('Contact', 'Site', ['ignore_request' => true]); + + // Set state as com_contact does in front-end + $model->setState('params', $app->getParams()); + $model->setState('contact.id', $id); + + $contact = $model->getItem(); + + // Basic sanity check + if (!empty($contact) && (int) ($contact->id ?? 0) === $id) { + return $contact; + } + } catch (\Throwable $e) { + } + + return null; + } + + /** + * Copy contact details into the role node. + * + * @param array $node Role node array (passed by reference). + * @param stdClass $contact com_contact record. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function fillNodeFromContact(array &$node, $contact): void + { + // Core fields (fill only if missing) + if (empty($node['name']) && !empty($contact->name)) { + $node['name'] = $contact->name; + } + if (empty($node['email']) && !empty($contact->email_to)) { + $node['email'] = $contact->email_to; + } + if (empty($node['url']) && !empty($contact->webpage)) { + $node['url'] = $contact->webpage; + } + + // Build a PostalAddress object as "address" + + if (isset($node['address']) && \is_array($node['address'])) { + $addr = $node['address']; + } else { + $addr = []; + } + + $addr['@type'] = 'PostalAddress'; + + + // Only set if missing to avoid overriding manually filled values + if (empty($addr['addressLocality'])) { + if (!empty($node['addressLocality'])) { + $addr['addressLocality'] = $node['addressLocality']; + } elseif (!empty($contact->suburb)) { + $addr['addressLocality'] = $contact->suburb; + } + } + + if (empty($addr['postalCode'])) { + if (!empty($node['postalCode'])) { + $addr['postalCode'] = $node['postalCode']; + } elseif (!empty($contact->postcode)) { + $addr['postalCode'] = $contact->postcode; + } + } + + if (empty($addr['streetAddress'])) { + if (!empty($node['streetAddress'])) { + $addr['streetAddress'] = $node['streetAddress']; + } elseif (!empty($contact->street)) { + $addr['streetAddress'] = $contact->street; + } elseif (!empty($contact->address)) { + // As a fallback, use the flat address if street isn’t split out + $addr['streetAddress'] = $contact->address; + } + } + + if (empty($addr['addressRegion'])) { + if (!empty($node['addressRegion'])) { + $addr['addressRegion'] = $node['addressRegion']; + } elseif (!empty($contact->state)) { + $addr['addressRegion'] = $contact->state; + } + } + + if (empty($addr['addressCountry'])) { + if (!empty($node['addressCountry'])) { + $addr['addressCountry'] = $node['addressCountry']; + } elseif (!empty($contact->country)) { + $addr['addressCountry'] = $contact->country; + } + } + + $node['address'] = $addr; + } + + /** * Saves form field data in the database * @@ -451,7 +830,10 @@ public function onBeforeCompileHead(BeforeCompileHeadApplicationEvent $event): v PluginHelper::importPlugin('schemaorg', null, true, $dispatcher); $dispatcher->dispatch('onSchemaBeforeCompileHead', $event); - $data = $schema->get('@graph'); + $data = $schema->get('@graph') ?: []; + + // Enrich contacts from com_contact + $this->enrichGraphContacts($data); foreach ($data as $key => $entry) { $data[$key] = $this->cleanupSchema($entry);