From 54bf5f77372937621baeddf30800fffc5afd257d Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 23 May 2026 17:50:28 -0500 Subject: [PATCH] feat(seo): add SEO meta tag management (closes #8) - Add seo_title, meta_description, robots, canonical_url columns - SQL migration for upgrades (01.01.00.sql) + updated install schema - New "SEO Meta Tags" fieldset in article/menu editor with: - SEO title (70 char max, overrides ) - Meta description (200 char max) - Robots directive (noindex, nofollow, nosnippet, etc.) - Canonical URL override - System plugin applies SEO tags in onBeforeCompileHead before OG tags - SEO audit column in admin tag list (missing desc, title too long, noindex) - Content plugin saves/loads all SEO fields alongside OG data Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/packages/com_mokoog/forms/tag.xml | 33 ++++++++++++ .../com_mokoog/language/en-GB/com_mokoog.ini | 25 +++++++++ .../com_mokoog/language/en-US/com_mokoog.ini | 25 +++++++++ src/packages/com_mokoog/sql/install.mysql.sql | 4 ++ .../com_mokoog/sql/updates/mysql/01.01.00.sql | 9 ++++ src/packages/com_mokoog/tmpl/tags/default.php | 27 ++++++++++ .../plg_content_mokoog/forms/mokoog.xml | 43 +++++++++++++++ .../language/en-GB/plg_content_mokoog.ini | 13 +++++ .../language/en-US/plg_content_mokoog.ini | 13 +++++ .../src/Extension/MokoOGContent.php | 32 +++++++---- .../src/Extension/MokoOG.php | 53 +++++++++++++++++-- 11 files changed, 264 insertions(+), 13 deletions(-) create mode 100644 src/packages/com_mokoog/sql/updates/mysql/01.01.00.sql diff --git a/src/packages/com_mokoog/forms/tag.xml b/src/packages/com_mokoog/forms/tag.xml index 363ef5f..3c1ab5c 100644 --- a/src/packages/com_mokoog/forms/tag.xml +++ b/src/packages/com_mokoog/forms/tag.xml @@ -70,4 +70,37 @@ <option value="0">JUNPUBLISHED</option> </field> </fieldset> + <fieldset name="seo" label="SEO Meta Tags"> + <field + name="seo_title" + type="text" + label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE" + description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC" + filter="string" + maxlength="70" + /> + <field + name="meta_description" + type="textarea" + label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION" + description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC" + filter="string" + rows="3" + maxlength="200" + /> + <field + name="robots" + type="text" + label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS" + description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC" + filter="string" + /> + <field + name="canonical_url" + type="url" + label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL" + description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC" + filter="url" + /> + </fieldset> </form> diff --git a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini b/src/packages/com_mokoog/language/en-GB/com_mokoog.ini index 0e56059..c3f3d80 100644 --- a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini +++ b/src/packages/com_mokoog/language/en-GB/com_mokoog.ini @@ -13,4 +13,29 @@ COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type" COM_MOKOOG_HEADING_CONTENT_ID="Content ID" COM_MOKOOG_HEADING_OG_TITLE="OG Title" COM_MOKOOG_HEADING_IMAGE="Image" +COM_MOKOOG_HEADING_SEO="SEO" COM_MOKOOG_HEADING_MODIFIED="Modified" + +COM_MOKOOG_SEO_OK="OK" +COM_MOKOOG_SEO_MISSING_DESC="No meta description" +COM_MOKOOG_SEO_TITLE_LONG="SEO title too long" +COM_MOKOOG_SEO_NOINDEX="noindex" + +COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type" +COM_MOKOOG_FIELD_CONTENT_ID="Content ID" +COM_MOKOOG_FIELD_OG_TITLE="OG Title" +COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing." +COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description" +COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing." +COM_MOKOOG_FIELD_OG_IMAGE="OG Image" +COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing." +COM_MOKOOG_FIELD_OG_TYPE="OG Type" +COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type." + +COM_MOKOOG_FILTER_SEARCH="Search OG titles" +COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type" +COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -" +COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending" +COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending" +COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending" +COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending" diff --git a/src/packages/com_mokoog/language/en-US/com_mokoog.ini b/src/packages/com_mokoog/language/en-US/com_mokoog.ini index 0e56059..c3f3d80 100644 --- a/src/packages/com_mokoog/language/en-US/com_mokoog.ini +++ b/src/packages/com_mokoog/language/en-US/com_mokoog.ini @@ -13,4 +13,29 @@ COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type" COM_MOKOOG_HEADING_CONTENT_ID="Content ID" COM_MOKOOG_HEADING_OG_TITLE="OG Title" COM_MOKOOG_HEADING_IMAGE="Image" +COM_MOKOOG_HEADING_SEO="SEO" COM_MOKOOG_HEADING_MODIFIED="Modified" + +COM_MOKOOG_SEO_OK="OK" +COM_MOKOOG_SEO_MISSING_DESC="No meta description" +COM_MOKOOG_SEO_TITLE_LONG="SEO title too long" +COM_MOKOOG_SEO_NOINDEX="noindex" + +COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type" +COM_MOKOOG_FIELD_CONTENT_ID="Content ID" +COM_MOKOOG_FIELD_OG_TITLE="OG Title" +COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing." +COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description" +COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing." +COM_MOKOOG_FIELD_OG_IMAGE="OG Image" +COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing." +COM_MOKOOG_FIELD_OG_TYPE="OG Type" +COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type." + +COM_MOKOOG_FILTER_SEARCH="Search OG titles" +COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type" +COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -" +COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending" +COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending" +COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending" +COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending" diff --git a/src/packages/com_mokoog/sql/install.mysql.sql b/src/packages/com_mokoog/sql/install.mysql.sql index 884d369..306bc3e 100644 --- a/src/packages/com_mokoog/sql/install.mysql.sql +++ b/src/packages/com_mokoog/sql/install.mysql.sql @@ -12,6 +12,10 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` ( `og_description` TEXT NOT NULL, `og_image` VARCHAR(512) NOT NULL DEFAULT '', `og_type` VARCHAR(50) NOT NULL DEFAULT 'article', + `seo_title` VARCHAR(70) NOT NULL DEFAULT '', + `meta_description` VARCHAR(200) NOT NULL DEFAULT '', + `robots` VARCHAR(100) NOT NULL DEFAULT '', + `canonical_url` VARCHAR(512) NOT NULL DEFAULT '', `published` TINYINT(1) NOT NULL DEFAULT 1, `created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', `modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', diff --git a/src/packages/com_mokoog/sql/updates/mysql/01.01.00.sql b/src/packages/com_mokoog/sql/updates/mysql/01.01.00.sql new file mode 100644 index 0000000..4f4c433 --- /dev/null +++ b/src/packages/com_mokoog/sql/updates/mysql/01.01.00.sql @@ -0,0 +1,9 @@ +-- +-- MokoOpenGraph 01.01.00 — Add SEO meta management columns +-- + +ALTER TABLE `#__mokoog_tags` + ADD COLUMN `seo_title` VARCHAR(70) NOT NULL DEFAULT '' AFTER `og_type`, + ADD COLUMN `meta_description` VARCHAR(200) NOT NULL DEFAULT '' AFTER `seo_title`, + ADD COLUMN `robots` VARCHAR(100) NOT NULL DEFAULT '' AFTER `meta_description`, + ADD COLUMN `canonical_url` VARCHAR(512) NOT NULL DEFAULT '' AFTER `robots`; diff --git a/src/packages/com_mokoog/tmpl/tags/default.php b/src/packages/com_mokoog/tmpl/tags/default.php index cfec7f7..779746f 100644 --- a/src/packages/com_mokoog/tmpl/tags/default.php +++ b/src/packages/com_mokoog/tmpl/tags/default.php @@ -50,6 +50,9 @@ use Joomla\CMS\Router\Route; <th scope="col" class="w-10"> <?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?> </th> + <th scope="col" class="w-10"> + <?php echo Text::_('COM_MOKOOG_HEADING_SEO'); ?> + </th> <th scope="col" class="w-10"> <?php echo Text::_('JSTATUS'); ?> </th> @@ -83,6 +86,30 @@ use Joomla\CMS\Router\Route; <span class="icon-minus-circle text-muted" aria-hidden="true"></span> <?php endif; ?> </td> + <td> + <?php + $seoIssues = []; + + if (empty($item->meta_description)) { + $seoIssues[] = Text::_('COM_MOKOOG_SEO_MISSING_DESC'); + } + + if (!empty($item->seo_title) && \strlen($item->seo_title) > 60) { + $seoIssues[] = Text::_('COM_MOKOOG_SEO_TITLE_LONG'); + } + + if (!empty($item->robots) && str_contains($item->robots, 'noindex')) { + $seoIssues[] = Text::_('COM_MOKOOG_SEO_NOINDEX'); + } + + if (empty($seoIssues)) : ?> + <span class="badge bg-success"><?php echo Text::_('COM_MOKOOG_SEO_OK'); ?></span> + <?php else : ?> + <?php foreach ($seoIssues as $issue) : ?> + <span class="badge bg-warning text-dark"><?php echo $issue; ?></span> + <?php endforeach; ?> + <?php endif; ?> + </td> <td> <?php echo $item->published ? Text::_('JPUBLISHED') : Text::_('JUNPUBLISHED'); ?> </td> diff --git a/src/packages/plg_content_mokoog/forms/mokoog.xml b/src/packages/plg_content_mokoog/forms/mokoog.xml index 1e9f026..9e5632e 100644 --- a/src/packages/plg_content_mokoog/forms/mokoog.xml +++ b/src/packages/plg_content_mokoog/forms/mokoog.xml @@ -50,5 +50,48 @@ <option value="video.other">Video</option> </field> </fieldset> + <fieldset name="mokoog_seo" label="PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL" + description="PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC"> + <field + name="seo_title" + type="text" + label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE" + description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC" + filter="string" + maxlength="70" + /> + <field + name="meta_description" + type="textarea" + label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION" + description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC" + filter="string" + rows="3" + maxlength="200" + /> + <field + name="robots" + type="list" + label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS" + description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC" + default="" + multiple="true" + > + <option value="">PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT</option> + <option value="noindex">noindex</option> + <option value="nofollow">nofollow</option> + <option value="nosnippet">nosnippet</option> + <option value="noarchive">noarchive</option> + <option value="noimageindex">noimageindex</option> + </field> + <field + name="canonical_url" + type="url" + label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL" + description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC" + filter="url" + validate="url" + /> + </fieldset> </fields> </form> diff --git a/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini b/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini index 6ea916c..167fe7e 100644 --- a/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini +++ b/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini @@ -13,3 +13,16 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image" PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image." PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type" PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page." + +PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags" +PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page." + +PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title" +PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom <title> tag. 50-60 characters recommended. Leave blank to use the default page title." +PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description" +PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default." +PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive" +PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)." +PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -" +PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL" +PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL." diff --git a/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini b/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini index 6ea916c..167fe7e 100644 --- a/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini +++ b/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini @@ -13,3 +13,16 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image" PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image." PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type" PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page." + +PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags" +PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page." + +PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title" +PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom <title> tag. 50-60 characters recommended. Leave blank to use the default page title." +PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description" +PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default." +PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive" +PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)." +PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -" +PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL" +PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL." diff --git a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php b/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php index 07c1161..3d70565 100644 --- a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php +++ b/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php @@ -176,7 +176,10 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface { $db = Factory::getDbo(); $query = $db->getQuery(true) - ->select($db->quoteName(['og_title', 'og_description', 'og_image', 'og_type'])) + ->select($db->quoteName([ + 'og_title', 'og_description', 'og_image', 'og_type', + '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); @@ -209,15 +212,26 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface $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, - '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'), - 'published' => 1, - 'modified' => Factory::getDate()->toSql(), + 'content_type' => $contentType, + 'content_id' => $contentId, + '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'), + '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) { diff --git a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php b/src/packages/plg_system_mokoog/src/Extension/MokoOG.php index 818e263..c245c42 100644 --- a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php +++ b/src/packages/plg_system_mokoog/src/Extension/MokoOG.php @@ -67,6 +67,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface // Try to load custom OG data from the database $ogData = $this->loadOgData($option, $view, $id); + // --- SEO meta tags (set first, before OG) --- + $this->applySeoTags($doc, $ogData); + // Build tag values — custom overrides auto-generated $title = $ogData->og_title ?: $doc->getTitle(); $description = $ogData->og_description ?: $this->buildDescription($doc); @@ -111,6 +114,44 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface } } + /** + * 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)) { + // Remove any existing canonical link first + foreach ($doc->_links as $link => $attribs) { + if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') { + unset($doc->_links[$link]); + } + } + + $doc->addHeadLink($ogData->canonical_url, 'canonical'); + } + } + /** * Load custom OG data from the database for the current page. * @@ -123,10 +164,14 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface private function loadOgData(string $option, string $view, int $id): object { $empty = (object) [ - 'og_title' => '', - 'og_description' => '', - 'og_image' => '', - 'og_type' => '', + 'og_title' => '', + 'og_description' => '', + 'og_image' => '', + 'og_type' => '', + 'seo_title' => '', + 'meta_description' => '', + 'robots' => '', + 'canonical_url' => '', ]; if (!$id) {