Compare commits

..

3 Commits

Author SHA1 Message Date
gitea-actions[bot] 979ac9823f chore: promote changelog [Unreleased] → [01.06.00] 2026-06-28 19:52:02 +00:00
gitea-actions[bot] 2fb7d10e39 chore(release): build 01.06.00 [skip ci] 2026-06-28 19:51:48 +00:00
jmiller 57333482e3 Merge PR #84: Joomla-7 forward-compatibility cleanup 2026-06-28 19:50:59 +00:00
49 changed files with 310 additions and 1053 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: mokocli.Automation
# VERSION: 01.06.09 # VERSION: 01.06.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
+6 -1
View File
@@ -2,10 +2,15 @@
## [Unreleased] ## [Unreleased]
## [01.06.00] --- 2026-06-28
## [01.06.00] --- 2026-06-28
## [01.05.00] --- 2026-06-28 ## [01.05.00] --- 2026-06-28
<!-- VERSION: 01.06.09 --> <!-- VERSION: 01.06.00 -->
All notable changes to MokoSuiteOpenGraph will be documented in this file. All notable changes to MokoSuiteOpenGraph will be documented in this file.
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Template-Joomla DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation INGROUP: Template-Joomla.Documentation
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/ REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
VERSION: 01.06.09 VERSION: 01.06.00
PATH: ./CODE_OF_CONDUCT.md PATH: ./CODE_OF_CONDUCT.md
BRIEF: Community expectations and enforcement guidelines BRIEF: Community expectations and enforcement guidelines
NOTE: Adapted with attribution from the Contributor Covenant v2.1 NOTE: Adapted with attribution from the Contributor Covenant v2.1
+1 -1
View File
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.Template-Joomla DEFGROUP: mokoconsulting-tech.Template-Joomla
INGROUP: MokoStandards.Governance INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/Template-Joomla REPO: https://github.com/mokoconsulting-tech/Template-Joomla
VERSION: 01.06.09 VERSION: 01.06.00
PATH: /GOVERNANCE.md PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for Template-Joomla BRIEF: Project governance rules, roles, and decision process for Template-Joomla
--> -->
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteOpenGraph # MokoSuiteOpenGraph
<!-- VERSION: 01.06.09 --> <!-- VERSION: 01.06.00 -->
Open Graph, Twitter Card, and social sharing meta tag management for Joomla 6 and higher. Open Graph, Twitter Card, and social sharing meta tag management for Joomla 6 and higher.
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md PATH: /SECURITY.md
VERSION: 01.06.09 VERSION: 01.06.00
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoSuiteOpenGraph
* @subpackage com_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
-->
<access component="com_mokoog">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.delete" title="JACTION_DELETE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="mokoog.batch" title="COM_MOKOOG_ACTION_BATCH" description="COM_MOKOOG_ACTION_BATCH_DESC" />
<action name="mokoog.import" title="COM_MOKOOG_ACTION_IMPORT" description="COM_MOKOOG_ACTION_IMPORT_DESC" />
</section>
</access>
@@ -31,14 +31,10 @@ class JsonapiView extends BaseApiView
'og_description', 'og_description',
'og_image', 'og_image',
'og_type', 'og_type',
'og_video',
'seo_title', 'seo_title',
'meta_description', 'meta_description',
'robots', 'robots',
'canonical_url', 'canonical_url',
'event_data',
'recipe_data',
'custom_schema',
'language', 'language',
'published', 'published',
'created', 'created',
@@ -58,14 +54,10 @@ class JsonapiView extends BaseApiView
'og_description', 'og_description',
'og_image', 'og_image',
'og_type', 'og_type',
'og_video',
'seo_title', 'seo_title',
'meta_description', 'meta_description',
'robots', 'robots',
'canonical_url', 'canonical_url',
'event_data',
'recipe_data',
'custom_schema',
'language', 'language',
'published', 'published',
'created', 'created',
-33
View File
@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoSuiteOpenGraph
* @subpackage com_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
-->
<config>
<fieldset name="general">
<field
type="note"
label="COM_MOKOOG_CONFIG_NOTE_LABEL"
description="COM_MOKOOG_CONFIG_NOTE_DESC"
/>
</fieldset>
<fieldset
name="permissions"
label="JCONFIG_PERMISSIONS_LABEL"
description="JCONFIG_PERMISSIONS_DESC"
>
<field
name="rules"
type="rules"
label="JCONFIG_PERMISSIONS_LABEL"
class="inputbox"
validate="rules"
filter="rules"
component="com_mokoog"
section="component"
/>
</fieldset>
</config>
+13 -23
View File
@@ -16,15 +16,13 @@
name="content_type" name="content_type"
type="text" type="text"
label="COM_MOKOOG_FIELD_CONTENT_TYPE" label="COM_MOKOOG_FIELD_CONTENT_TYPE"
description="COM_MOKOOG_FIELD_CONTENT_TYPE_DESC" readonly="true"
required="true"
/> />
<field <field
name="content_id" name="content_id"
type="number" type="number"
label="COM_MOKOOG_FIELD_CONTENT_ID" label="COM_MOKOOG_FIELD_CONTENT_ID"
description="COM_MOKOOG_FIELD_CONTENT_ID_DESC" readonly="true"
required="true"
/> />
<field <field
name="og_title" name="og_title"
@@ -79,45 +77,37 @@
<option value="1">JPUBLISHED</option> <option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</option> <option value="0">JUNPUBLISHED</option>
</field> </field>
<field
name="language"
type="contentlanguage"
label="JFIELD_LANGUAGE_LABEL"
default="*"
>
<option value="*">JALL</option>
</field>
</fieldset> </fieldset>
<fieldset name="seo" label="COM_MOKOOG_FIELDSET_SEO"> <fieldset name="seo" label="SEO Meta Tags">
<field <field
name="seo_title" name="seo_title"
type="text" type="text"
label="COM_MOKOOG_FIELD_SEO_TITLE" label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
description="COM_MOKOOG_FIELD_SEO_TITLE_DESC" description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
filter="string" filter="string"
maxlength="70" maxlength="255"
/> />
<field <field
name="meta_description" name="meta_description"
type="textarea" type="textarea"
label="COM_MOKOOG_FIELD_META_DESCRIPTION" label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
description="COM_MOKOOG_FIELD_META_DESCRIPTION_DESC" description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
filter="string" filter="string"
rows="3" rows="3"
maxlength="200" maxlength="255"
/> />
<field <field
name="robots" name="robots"
type="text" type="text"
label="COM_MOKOOG_FIELD_ROBOTS" label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
description="COM_MOKOOG_FIELD_ROBOTS_DESC" description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
filter="string" filter="string"
/> />
<field <field
name="canonical_url" name="canonical_url"
type="url" type="url"
label="COM_MOKOOG_FIELD_CANONICAL_URL" label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
description="COM_MOKOOG_FIELD_CANONICAL_URL_DESC" description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
filter="url" filter="url"
/> />
</fieldset> </fieldset>
@@ -5,13 +5,6 @@
COM_MOKOOG="MokoSuiteOpenGraph" COM_MOKOOG="MokoSuiteOpenGraph"
COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager" COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
COM_MOKOOG_SUBMENU_TAGS="Tags" COM_MOKOOG_SUBMENU_TAGS="Tags"
COM_MOKOOG_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOOG_DASHBOARD_TITLE="MokoSuiteOpenGraph - Dashboard"
COM_MOKOOG_DASHBOARD_FIELD_GAPS="Field Coverage Gaps"
COM_MOKOOG_DASHBOARD_BY_TYPE="Coverage by Content Type"
COM_MOKOOG_DASHBOARD_MISSING="Articles Missing OG Tags"
COM_MOKOOG_DASHBOARD_ALL_COVERED="All published articles have OG tags."
COM_MOKOOG_DASHBOARD_MISSING_NOTE="Showing up to 20 most recent. Use Batch Generate to create OG tags for all articles at once."
COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items."
COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags"
COM_MOKOOG_AUTO_GENERATED="auto-generated" COM_MOKOOG_AUTO_GENERATED="auto-generated"
@@ -73,27 +66,3 @@ COM_MOKOOG_COVERAGE_ARTICLES="%d of %d articles have OG tags"
COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title" COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title"
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description" COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image" COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image"
; Single-tag edit form
COM_MOKOOG_TAG_NEW="MokoSuiteOpenGraph - New OG Tag"
COM_MOKOOG_TAG_EDIT="MokoSuiteOpenGraph - Edit OG Tag"
COM_MOKOOG_TAB_DETAILS="Details"
COM_MOKOOG_FIELDSET_SEO="SEO Meta Tags"
COM_MOKOOG_FIELD_CONTENT_TYPE_DESC="The content type this OG tag applies to (e.g. com_content, menu, com_content.category)."
COM_MOKOOG_FIELD_CONTENT_ID_DESC="The ID of the content item this OG tag applies to."
COM_MOKOOG_FIELD_SEO_TITLE="SEO Title"
COM_MOKOOG_FIELD_SEO_TITLE_DESC="Overrides the page &lt;title&gt; tag (max 70 characters)."
COM_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
COM_MOKOOG_FIELD_META_DESCRIPTION_DESC="Overrides the page meta description (max 200 characters)."
COM_MOKOOG_FIELD_ROBOTS="Robots"
COM_MOKOOG_FIELD_ROBOTS_DESC="Per-page robots directive, e.g. noindex, nofollow."
COM_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
COM_MOKOOG_FIELD_CANONICAL_URL_DESC="Overrides the canonical URL for this content item (http/https only)."
; ACL actions (access.xml) and component options (config.xml)
COM_MOKOOG_ACTION_BATCH="Batch Generate OG Tags"
COM_MOKOOG_ACTION_BATCH_DESC="Allows users in this group to run batch OG tag generation."
COM_MOKOOG_ACTION_IMPORT="Import / Export OG Tags"
COM_MOKOOG_ACTION_IMPORT_DESC="Allows users in this group to import and export OG tags via CSV."
COM_MOKOOG_CONFIG_NOTE_LABEL="Where are the settings?"
COM_MOKOOG_CONFIG_NOTE_DESC="Open Graph and SEO settings are configured in the System - MokoSuiteOpenGraph plugin (Extensions &#8594; Plugins). This screen manages component permissions only."
@@ -5,13 +5,6 @@
COM_MOKOOG="MokoSuiteOpenGraph" COM_MOKOOG="MokoSuiteOpenGraph"
COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager" COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
COM_MOKOOG_SUBMENU_TAGS="Tags" COM_MOKOOG_SUBMENU_TAGS="Tags"
COM_MOKOOG_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOOG_DASHBOARD_TITLE="MokoSuiteOpenGraph - Dashboard"
COM_MOKOOG_DASHBOARD_FIELD_GAPS="Field Coverage Gaps"
COM_MOKOOG_DASHBOARD_BY_TYPE="Coverage by Content Type"
COM_MOKOOG_DASHBOARD_MISSING="Articles Missing OG Tags"
COM_MOKOOG_DASHBOARD_ALL_COVERED="All published articles have OG tags."
COM_MOKOOG_DASHBOARD_MISSING_NOTE="Showing up to 20 most recent. Use Batch Generate to create OG tags for all articles at once."
COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items."
COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags"
COM_MOKOOG_AUTO_GENERATED="auto-generated" COM_MOKOOG_AUTO_GENERATED="auto-generated"
@@ -73,27 +66,3 @@ COM_MOKOOG_COVERAGE_ARTICLES="%d of %d articles have OG tags"
COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title" COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title"
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description" COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image" COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image"
; Single-tag edit form
COM_MOKOOG_TAG_NEW="MokoSuiteOpenGraph - New OG Tag"
COM_MOKOOG_TAG_EDIT="MokoSuiteOpenGraph - Edit OG Tag"
COM_MOKOOG_TAB_DETAILS="Details"
COM_MOKOOG_FIELDSET_SEO="SEO Meta Tags"
COM_MOKOOG_FIELD_CONTENT_TYPE_DESC="The content type this OG tag applies to (e.g. com_content, menu, com_content.category)."
COM_MOKOOG_FIELD_CONTENT_ID_DESC="The ID of the content item this OG tag applies to."
COM_MOKOOG_FIELD_SEO_TITLE="SEO Title"
COM_MOKOOG_FIELD_SEO_TITLE_DESC="Overrides the page &lt;title&gt; tag (max 70 characters)."
COM_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
COM_MOKOOG_FIELD_META_DESCRIPTION_DESC="Overrides the page meta description (max 200 characters)."
COM_MOKOOG_FIELD_ROBOTS="Robots"
COM_MOKOOG_FIELD_ROBOTS_DESC="Per-page robots directive, e.g. noindex, nofollow."
COM_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
COM_MOKOOG_FIELD_CANONICAL_URL_DESC="Overrides the canonical URL for this content item (http/https only)."
; ACL actions (access.xml) and component options (config.xml)
COM_MOKOOG_ACTION_BATCH="Batch Generate OG Tags"
COM_MOKOOG_ACTION_BATCH_DESC="Allows users in this group to run batch OG tag generation."
COM_MOKOOG_ACTION_IMPORT="Import / Export OG Tags"
COM_MOKOOG_ACTION_IMPORT_DESC="Allows users in this group to import and export OG tags via CSV."
COM_MOKOOG_CONFIG_NOTE_LABEL="Where are the settings?"
COM_MOKOOG_CONFIG_NOTE_DESC="Open Graph and SEO settings are configured in the System - MokoSuiteOpenGraph plugin (Extensions &#8594; Plugins). This screen manages component permissions only."
+1 -9
View File
@@ -8,7 +8,7 @@
--> -->
<extension type="component" method="upgrade"> <extension type="component" method="upgrade">
<name>com_mokoog</name> <name>com_mokoog</name>
<version>01.06.09</version> <version>01.06.00</version>
<creationDate>2026-05-23</creationDate> <creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -50,8 +50,6 @@
<folder>View</folder> <folder>View</folder>
</files> </files>
<files folder="tmpl"> <files folder="tmpl">
<folder>dashboard</folder>
<folder>tag</folder>
<folder>tags</folder> <folder>tags</folder>
</files> </files>
<files folder="sql"> <files folder="sql">
@@ -65,15 +63,9 @@
</files> </files>
<files folder="language"> <files folder="language">
<folder>en-GB</folder> <folder>en-GB</folder>
<folder>en-US</folder>
</files>
<files>
<filename>access.xml</filename>
<filename>config.xml</filename>
</files> </files>
<menu img="class:bookmark">COM_MOKOOG</menu> <menu img="class:bookmark">COM_MOKOOG</menu>
<submenu> <submenu>
<menu link="option=com_mokoog&amp;view=dashboard">COM_MOKOOG_SUBMENU_DASHBOARD</menu>
<menu link="option=com_mokoog&amp;view=tags">COM_MOKOOG_SUBMENU_TAGS</menu> <menu link="option=com_mokoog&amp;view=tags">COM_MOKOOG_SUBMENU_TAGS</menu>
</submenu> </submenu>
</administration> </administration>
@@ -1 +0,0 @@
/* 01.05.02 — no schema changes */
@@ -0,0 +1 @@
/* 01.06.00 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.02 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.03 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.04 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.05 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.06 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.07 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.08 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.09 — no schema changes */
@@ -29,10 +29,7 @@ class BatchController extends BaseController
{ {
Session::checkToken('get') || throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403); Session::checkToken('get') || throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
$identity = Factory::getApplication()->getIdentity(); if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
if (!$identity->authorise('mokoog.batch', 'com_mokoog')
&& !$identity->authorise('core.create', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
} }
@@ -65,10 +62,7 @@ class BatchController extends BaseController
{ {
Session::checkToken('get') || throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403); Session::checkToken('get') || throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
$identity = Factory::getApplication()->getIdentity(); if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
if (!$identity->authorise('mokoog.batch', 'com_mokoog')
&& !$identity->authorise('core.create', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
} }
@@ -21,5 +21,5 @@ class DisplayController extends BaseController
* *
* @var string * @var string
*/ */
protected $default_view = 'dashboard'; protected $default_view = 'tags';
} }
@@ -60,10 +60,6 @@ class ImportExportController extends BaseController
$db->quoteName('t.robots'), $db->quoteName('t.robots'),
$db->quoteName('t.canonical_url'), $db->quoteName('t.canonical_url'),
$db->quoteName('t.language'), $db->quoteName('t.language'),
$db->quoteName('t.og_video'),
$db->quoteName('t.event_data'),
$db->quoteName('t.recipe_data'),
$db->quoteName('t.custom_schema'),
]) ])
->from($db->quoteName('#__mokoog_tags', 't')) ->from($db->quoteName('#__mokoog_tags', 't'))
->leftJoin( ->leftJoin(
@@ -88,7 +84,7 @@ class ImportExportController extends BaseController
'content_type', 'content_id', 'article_title', 'content_type', 'content_id', 'article_title',
'og_title', 'og_description', 'og_image', 'og_type', 'og_title', 'og_description', 'og_image', 'og_type',
'seo_title', 'meta_description', 'robots', 'canonical_url', 'seo_title', 'meta_description', 'robots', 'canonical_url',
'language', 'og_video', 'event_data', 'recipe_data', 'custom_schema', 'language',
]); ]);
foreach ($rows as $row) { foreach ($rows as $row) {
@@ -110,8 +106,7 @@ class ImportExportController extends BaseController
$identity = Factory::getApplication()->getIdentity(); $identity = Factory::getApplication()->getIdentity();
if (!$identity->authorise('mokoog.import', 'com_mokoog') if (!$identity->authorise('core.create', 'com_mokoog') || !$identity->authorise('core.edit', 'com_mokoog')) {
&& !($identity->authorise('core.create', 'com_mokoog') && $identity->authorise('core.edit', 'com_mokoog'))) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
} }
@@ -192,10 +187,6 @@ class ImportExportController extends BaseController
$robots = trim($row[9] ?? ''); $robots = trim($row[9] ?? '');
$canonicalUrl = trim($row[10] ?? ''); $canonicalUrl = trim($row[10] ?? '');
$language = trim($row[11] ?? '*'); $language = trim($row[11] ?? '*');
$ogVideo = $this->sanitizeUrl($row[12] ?? '');
$eventData = $this->validateJsonField($row[13] ?? '');
$recipeData = $this->validateJsonField($row[14] ?? '');
$customSchema = $this->validateJsonField($row[15] ?? '');
// Validate language tag format (e.g., 'en-GB', '*') // Validate language tag format (e.g., 'en-GB', '*')
if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) { if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) {
@@ -238,10 +229,6 @@ class ImportExportController extends BaseController
'robots' => $robots, 'robots' => $robots,
'canonical_url' => $canonicalUrl, 'canonical_url' => $canonicalUrl,
'language' => $language, 'language' => $language,
'og_video' => $ogVideo,
'event_data' => $eventData,
'recipe_data' => $recipeData,
'custom_schema' => $customSchema,
'published' => 1, 'published' => 1,
'modified' => $now, 'modified' => $now,
]; ];
@@ -265,45 +252,4 @@ class ImportExportController extends BaseController
); );
$app->redirect('index.php?option=com_mokoog&view=tags'); $app->redirect('index.php?option=com_mokoog&view=tags');
} }
/**
* Validate a JSON field — returns trimmed JSON only if it is an object/array.
*
* Scalars and invalid JSON are dropped to '' so an import can never inject a
* payload that crashes the frontend JSON-LD renderer.
*
* @param string $value Raw CSV cell value
*
* @return string
*/
private function validateJsonField(string $value): string
{
$value = trim($value);
if ($value === '' || !\is_array(json_decode($value, true))) {
return '';
}
return $value;
}
/**
* Sanitize a URL to only allow http/https schemes.
*
* @param string $url Raw CSV cell value
*
* @return string Sanitized URL or empty string
*/
private function sanitizeUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return \in_array($scheme, ['http', 'https'], true) ? $url : '';
}
} }
@@ -1,31 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_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\Component\MokoOG\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\FormController;
/**
* Controller for a single OG tag record.
*
* Provides the standard add/edit/save/apply/cancel tasks via FormController,
* backed by the existing TagModel (AdminModel) and TagTable.
*/
class TagController extends FormController
{
/**
* The list view to redirect to after save/cancel.
*
* @var string
*/
protected $view_list = 'tags';
}
@@ -0,0 +1 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,159 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_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\Component\MokoOG\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
/**
* Read-only model providing OG tag coverage metrics for the dashboard.
*/
class DashboardModel extends BaseDatabaseModel
{
/**
* Overall coverage statistics for com_content articles.
*
* @return array{total:int, with_og:int, coverage:int, missing_title:int, missing_description:int, missing_image:int}
*/
public function getStats(): array
{
$db = $this->getDatabase();
$total = $this->countContent();
$withOg = $this->countDistinct();
$missingTitle = $this->countEmptyField('og_title');
$missingDesc = $this->countEmptyField('og_description');
$missingImage = $this->countEmptyField('og_image');
return [
'total' => $total,
'with_og' => $withOg,
'coverage' => $total > 0 ? (int) round(($withOg / $total) * 100) : 0,
'missing_title' => $missingTitle,
'missing_description' => $missingDesc,
'missing_image' => $missingImage,
];
}
/**
* Coverage broken down by content_type.
*
* @return array Rows of {content_type, total, with_title, with_image}
*/
public function getCoverageByType(): array
{
$db = $this->getDatabase();
$empty = $db->quote('');
$query = $db->getQuery(true)
->select([
$db->quoteName('content_type'),
'COUNT(*) AS ' . $db->quoteName('total'),
'SUM(CASE WHEN ' . $db->quoteName('og_title') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_title'),
'SUM(CASE WHEN ' . $db->quoteName('og_image') . ' <> ' . $empty . ' THEN 1 ELSE 0 END) AS ' . $db->quoteName('with_image'),
])
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('published') . ' = 1')
->group($db->quoteName('content_type'))
->order($db->quoteName('content_type') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
/**
* Published articles that have no OG tag yet.
*
* @param int $limit Maximum rows to return
*
* @return array Rows of {id, title}
*/
public function getMissingArticles(int $limit = 20): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select([$db->quoteName('c.id'), $db->quoteName('c.title')])
->from($db->quoteName('#__content', 'c'))
->leftJoin(
$db->quoteName('#__mokoog_tags', 't')
. ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content')
. ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id')
)
->where($db->quoteName('c.state') . ' = 1')
->where($db->quoteName('t.id') . ' IS NULL')
->order($db->quoteName('c.id') . ' DESC');
$db->setQuery($query, 0, max(1, $limit));
return $db->loadObjectList() ?: [];
}
/**
* Count published com_content articles.
*/
private function countContent(): int
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__content'))
->where($db->quoteName('state') . ' = 1')
);
return (int) $db->loadResult();
}
/**
* Count distinct articles that have at least one published OG tag.
*/
private function countDistinct(): int
{
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(DISTINCT ' . $db->quoteName('content_id') . ')')
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('published') . ' = 1')
);
return (int) $db->loadResult();
}
/**
* Count published OG tag rows whose given field is empty.
*
* @param string $field One of og_title, og_description, og_image
*/
private function countEmptyField(string $field): int
{
// Whitelist the column name — it is never user input here, but keep it strict.
if (!\in_array($field, ['og_title', 'og_description', 'og_image'], true)) {
return 0;
}
$db = $this->getDatabase();
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
->where($db->quoteName('published') . ' = 1')
->where($db->quoteName($field) . ' = ' . $db->quote(''))
);
return (int) $db->loadResult();
}
}
@@ -0,0 +1 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,76 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_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\Component\MokoOG\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Dashboard view — OG tag coverage metrics.
*/
class HtmlView extends BaseHtmlView
{
/**
* Overall coverage stats.
*
* @var array
*/
protected $stats = [];
/**
* Coverage broken down by content_type.
*
* @var array
*/
protected $byType = [];
/**
* Published articles missing an OG tag.
*
* @var array
*/
protected $missing = [];
/**
* Display the view.
*
* @param string $tpl Template name
*
* @return void
*/
public function display($tpl = null): void
{
/** @var \Joomla\Component\MokoOG\Administrator\Model\DashboardModel $model */
$model = $this->getModel();
$this->stats = $model->getStats();
$this->byType = $model->getCoverageByType();
$this->missing = $model->getMissingArticles(20);
$this->addToolbar();
parent::display($tpl);
}
/**
* Add the toolbar.
*
* @return void
*/
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOOG_DASHBOARD_TITLE'), 'bookmark');
ToolbarHelper::preferences('com_mokoog');
}
}
@@ -1,76 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_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\Component\MokoOG\Administrator\View\Tag;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
/**
* Edit view for a single OG tag record.
*/
class HtmlView extends BaseHtmlView
{
/**
* The edit form.
*
* @var \Joomla\CMS\Form\Form
*/
protected $form;
/**
* The item being edited.
*
* @var object
*/
protected $item;
/**
* Display the view.
*
* @param string $tpl Template name
*
* @return void
*/
public function display($tpl = null): void
{
$this->form = $this->get('Form');
$this->item = $this->get('Item');
$this->addToolbar();
parent::display($tpl);
}
/**
* Add the edit toolbar.
*
* @return void
*/
protected function addToolbar(): void
{
Factory::getApplication()->getInput()->set('hidemainmenu', true);
$isNew = empty($this->item->id);
ToolbarHelper::title(
Text::_($isNew ? 'COM_MOKOOG_TAG_NEW' : 'COM_MOKOOG_TAG_EDIT'),
'bookmark'
);
ToolbarHelper::apply('tag.apply');
ToolbarHelper::save('tag.save');
ToolbarHelper::cancel('tag.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
}
}
@@ -81,11 +81,8 @@ class HtmlView extends BaseHtmlView
protected function addToolbar(): void protected function addToolbar(): void
{ {
ToolbarHelper::title(Text::_('COM_MOKOOG_TAGS_TITLE'), 'bookmark'); ToolbarHelper::title(Text::_('COM_MOKOOG_TAGS_TITLE'), 'bookmark');
ToolbarHelper::addNew('tag.add');
ToolbarHelper::editList('tag.edit');
ToolbarHelper::custom('batch.generate', 'refresh', '', 'COM_MOKOOG_TOOLBAR_BATCH_GENERATE', false); ToolbarHelper::custom('batch.generate', 'refresh', '', 'COM_MOKOOG_TOOLBAR_BATCH_GENERATE', false);
ToolbarHelper::custom('importexport.export', 'download', '', 'COM_MOKOOG_TOOLBAR_EXPORT', false); ToolbarHelper::custom('importexport.export', 'download', '', 'COM_MOKOOG_TOOLBAR_EXPORT', false);
ToolbarHelper::custom('mokoog.showimport', 'upload', '', 'COM_MOKOOG_TOOLBAR_IMPORT', false);
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete'); ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete');
ToolbarHelper::preferences('com_mokoog'); ToolbarHelper::preferences('com_mokoog');
} }
@@ -1,142 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_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
*/
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
/** @var \Joomla\Component\MokoOG\Administrator\View\Dashboard\HtmlView $this */
$s = $this->stats;
$coverage = (int) ($s['coverage'] ?? 0);
$total = (int) ($s['total'] ?? 0);
$withOg = (int) ($s['with_og'] ?? 0);
$colorClass = $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger');
$stroke = $coverage >= 80 ? '#198754' : ($coverage >= 50 ? '#ffc107' : '#dc3545');
$r = 54.0;
$circ = 2 * M_PI * $r;
$dash = round($circ * $coverage / 100, 2);
$gap = round($circ - $dash, 2);
?>
<div class="p-3">
<div class="row">
<!-- Coverage donut -->
<div class="col-lg-4 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_COVERAGE_PERCENT'); ?></h4>
<svg width="160" height="160" viewBox="0 0 140 140" role="img"
aria-label="<?php echo $coverage; ?>%" class="<?php echo $colorClass; ?>">
<circle cx="70" cy="70" r="54" fill="none" stroke="#e9ecef" stroke-width="14"></circle>
<circle cx="70" cy="70" r="54" fill="none" stroke="<?php echo $stroke; ?>" stroke-width="14"
stroke-dasharray="<?php echo $dash; ?> <?php echo $gap; ?>"
stroke-linecap="round" transform="rotate(-90 70 70)"></circle>
<text x="70" y="80" text-anchor="middle" font-size="30" font-weight="bold" fill="currentColor"><?php echo $coverage; ?>%</text>
</svg>
<p class="mt-2 mb-0"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_ARTICLES', $withOg, $total); ?></p>
</div>
</div>
</div>
<!-- Missing fields -->
<div class="col-lg-8 mb-3">
<div class="card h-100">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_DASHBOARD_FIELD_GAPS'); ?></h4>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between">
<span><?php echo Text::_('COM_MOKOOG_HEADING_OG_TITLE'); ?></span>
<span class="badge bg-<?php echo ($s['missing_title'] ?? 0) ? 'warning text-dark' : 'success'; ?>"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_TITLE', (int) ($s['missing_title'] ?? 0)); ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<span><?php echo Text::_('COM_MOKOOG_FIELD_OG_DESCRIPTION'); ?></span>
<span class="badge bg-<?php echo ($s['missing_description'] ?? 0) ? 'warning text-dark' : 'success'; ?>"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_DESC', (int) ($s['missing_description'] ?? 0)); ?></span>
</li>
<li class="list-group-item d-flex justify-content-between">
<span><?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?></span>
<span class="badge bg-<?php echo ($s['missing_image'] ?? 0) ? 'warning text-dark' : 'success'; ?>"><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_IMAGE', (int) ($s['missing_image'] ?? 0)); ?></span>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Coverage by content type -->
<div class="col-lg-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_DASHBOARD_BY_TYPE'); ?></h4>
<?php if (empty($this->byType)) : ?>
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOOG_NO_TAGS'); ?></p>
<?php else : ?>
<table class="table table-sm mb-0">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOOG_HEADING_CONTENT_TYPE'); ?></th>
<th class="text-end"><?php echo Text::_('JGRID_HEADING_ID'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKOOG_HEADING_OG_TITLE'); ?></th>
<th class="text-end"><?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($this->byType as $row) : ?>
<tr>
<td><?php echo $this->escape($row->content_type); ?></td>
<td class="text-end"><?php echo (int) $row->total; ?></td>
<td class="text-end"><?php echo (int) $row->with_title; ?></td>
<td class="text-end"><?php echo (int) $row->with_image; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
</div>
<!-- Articles missing OG tags -->
<div class="col-lg-6 mb-3">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h4 class="card-title mb-0"><?php echo Text::_('COM_MOKOOG_DASHBOARD_MISSING'); ?></h4>
<a class="btn btn-sm btn-primary" href="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>">
<span class="icon-refresh" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOOG_TOOLBAR_BATCH_GENERATE'); ?>
</a>
</div>
<?php if (empty($this->missing)) : ?>
<p class="text-success mb-0">
<span class="icon-check" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOOG_DASHBOARD_ALL_COVERED'); ?>
</p>
<?php else : ?>
<ul class="list-group list-group-flush">
<?php foreach ($this->missing as $article) : ?>
<li class="list-group-item py-1">
<a href="<?php echo Route::_('index.php?option=com_content&task=article.edit&id=' . (int) $article->id); ?>">
<?php echo $this->escape($article->title); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<small class="text-muted d-block mt-2"><?php echo Text::_('COM_MOKOOG_DASHBOARD_MISSING_NOTE'); ?></small>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
@@ -1,41 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_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
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
/** @var \Joomla\Component\MokoOG\Administrator\View\Tag\HtmlView $this */
HTMLHelper::_('behavior.formvalidator');
?>
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tag&layout=edit&id=' . (int) ($this->item->id ?? 0)); ?>"
method="post" name="adminForm" id="adminForm" class="form-validate" aria-label="<?php echo $this->escape(Text::_('COM_MOKOOG_TAG_EDIT')); ?>">
<div class="row">
<div class="col-lg-9">
<?php echo HTMLHelper::_('uitab.startTabSet', 'mokoogTab', ['active' => 'details']); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'mokoogTab', 'details', Text::_('COM_MOKOOG_TAB_DETAILS')); ?>
<?php echo $this->form->renderFieldset('details'); ?>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.addTab', 'mokoogTab', 'seo', Text::_('COM_MOKOOG_FIELDSET_SEO')); ?>
<?php echo $this->form->renderFieldset('seo'); ?>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
</div>
</div>
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
@@ -0,0 +1,58 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_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
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
// Total published articles
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__content')->where('state = 1'));
$totalArticles = (int) $db->loadResult();
// Articles with OG tags
$db->setQuery($db->getQuery(true)->select('COUNT(DISTINCT content_id)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where('published = 1'));
$articlesWithOg = (int) $db->loadResult();
// Articles missing OG data fields
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_title = ''")->where('published = 1'));
$missingTitle = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_description = ''")->where('published = 1'));
$missingDesc = (int) $db->loadResult();
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_image = ''")->where('published = 1'));
$missingImage = (int) $db->loadResult();
$coverage = $totalArticles > 0 ? round(($articlesWithOg / $totalArticles) * 100) : 0;
?>
<div class="mokoog-coverage card mb-3">
<div class="card-body">
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_COVERAGE_TITLE'); ?></h4>
<div class="row">
<div class="col-md-3 text-center">
<div class="display-4 <?php echo $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger'); ?>">
<?php echo $coverage; ?>%
</div>
<small class="text-muted"><?php echo Text::_('COM_MOKOOG_COVERAGE_PERCENT'); ?></small>
</div>
<div class="col-md-9">
<ul class="list-unstyled">
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_ARTICLES', $articlesWithOg, $totalArticles); ?></li>
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_TITLE', $missingTitle); ?></li>
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_DESC', $missingDesc); ?></li>
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_IMAGE', $missingImage); ?></li>
</ul>
</div>
</div>
</div>
</div>
@@ -21,6 +21,7 @@ use Joomla\CMS\Session\Session;
$token = Session::getFormToken(); $token = Session::getFormToken();
?> ?>
<?php include __DIR__ . '/coverage.php'; ?>
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm"> <form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
@@ -84,9 +85,7 @@ $token = Session::getFormToken();
<?php echo (int) $item->content_id; ?> <?php echo (int) $item->content_id; ?>
</td> </td>
<td> <td>
<a href="<?php echo Route::_('index.php?option=com_mokoog&task=tag.edit&id=' . (int) $item->id); ?>" title="<?php echo Text::_('JACTION_EDIT'); ?>"> <?php echo $this->escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?>
<?php echo $this->escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?>
</a>
</td> </td>
<td> <td>
<?php if ($item->og_image) : ?> <?php if ($item->og_image) : ?>
@@ -172,23 +171,6 @@ $token = Session::getFormToken();
</div> </div>
</div> </div>
<!-- CSV Import -->
<div id="mokoog-import-panel" style="display:none;" class="card mt-3">
<div class="card-body">
<h4><?php echo Text::_('COM_MOKOOG_TOOLBAR_IMPORT'); ?></h4>
<form action="<?php echo Route::_('index.php?option=com_mokoog&task=importexport.import'); ?>" method="post" enctype="multipart/form-data" class="mt-2">
<div class="mb-2">
<input type="file" name="jform[csv_file]" accept=".csv" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOOG_TOOLBAR_IMPORT'); ?>
</button>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Intercept the batch.generate toolbar button // Intercept the batch.generate toolbar button
@@ -198,13 +180,6 @@ document.addEventListener('DOMContentLoaded', function() {
mokoogBatchGenerate(); mokoogBatchGenerate();
return; return;
} }
if (task === 'mokoog.showimport') {
var ip = document.getElementById('mokoog-import-panel');
if (ip) {
ip.style.display = (ip.style.display === 'none' ? 'block' : 'none');
}
return;
}
if (origSubmitbutton) { if (origSubmitbutton) {
origSubmitbutton(task); origSubmitbutton(task);
} }
@@ -8,7 +8,7 @@
--> -->
<extension type="plugin" group="content" method="upgrade"> <extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteOpenGraph</name> <name>Content - MokoSuiteOpenGraph</name>
<version>01.06.09</version> <version>01.06.00</version>
<creationDate>2026-05-23</creationDate> <creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -322,14 +322,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
{ {
$json = trim($json); $json = trim($json);
if ($json === '') { if ($json === '' || json_decode($json) === null) {
return '';
}
// Only accept JSON objects/arrays. Scalars (42, "x", true) decode to a
// non-null value but would crash the frontend renderer when treated as
// an array (writing $decoded['@context'] onto a scalar is a fatal error).
if (!\is_array(json_decode($json, true))) {
return ''; return '';
} }
+1 -1
View File
@@ -8,7 +8,7 @@
--> -->
<extension type="plugin" group="system" method="upgrade"> <extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteOpenGraph</name> <name>System - MokoSuiteOpenGraph</name>
<version>01.06.09</version> <version>01.06.00</version>
<creationDate>2026-05-23</creationDate> <creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -139,7 +139,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
} }
// og:locale from current language // og:locale from current language
$langTag = $this->getApplication()->getLanguage()->getTag(); $langTag = Factory::getLanguage()->getTag();
$ogLocale = str_replace('-', '_', $langTag); $ogLocale = str_replace('-', '_', $langTag);
$doc->setMetaData('og:locale', $ogLocale, 'property'); $doc->setMetaData('og:locale', $ogLocale, 'property');
@@ -358,9 +358,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
if (!empty($customSchema)) { if (!empty($customSchema)) {
$decoded = json_decode($customSchema, true); $decoded = json_decode($customSchema, true);
// Guard against scalar/invalid payloads — only arrays/objects are if ($decoded) {
// valid JSON-LD. Writing an array offset onto a scalar is fatal.
if (\is_array($decoded) && $decoded !== []) {
if (empty($decoded['@context'])) { if (empty($decoded['@context'])) {
$decoded['@context'] = 'https://schema.org'; $decoded['@context'] = 'https://schema.org';
} }
@@ -476,7 +474,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
->where($db->quoteName('content_type') . ' = ' . $db->quote($option)) ->where($db->quoteName('content_type') . ' = ' . $db->quote($option))
->where($db->quoteName('content_id') . ' = ' . (int) $id) ->where($db->quoteName('content_id') . ' = ' . (int) $id)
->where($db->quoteName('published') . ' = 1') ->where($db->quoteName('published') . ' = 1')
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($this->getApplication()->getLanguage()->getTag()) ->where('(' . $db->quoteName('language') . ' = ' . $db->quote(Factory::getLanguage()->getTag())
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')') . ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC'); ->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
@@ -496,7 +494,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
private function loadOgDataByType(string $contentType, int $contentId): ?object private function loadOgDataByType(string $contentType, int $contentId): ?object
{ {
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$lang = $this->getApplication()->getLanguage()->getTag(); $lang = Factory::getLanguage()->getTag();
$query = $db->getQuery(true) $query = $db->getQuery(true)
->select('*') ->select('*')
@@ -523,7 +521,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
private function loadOgDataByMenu(int $menuId): ?object private function loadOgDataByMenu(int $menuId): ?object
{ {
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$lang = $this->getApplication()->getLanguage()->getTag(); $lang = Factory::getLanguage()->getTag();
$query = $db->getQuery(true) $query = $db->getQuery(true)
->select('*') ->select('*')
@@ -672,9 +670,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
{ {
static $cache = []; static $cache = [];
// array_key_exists (not isset) so a negative lookup (null) is also cached if (isset($cache[$id])) {
// and not re-queried on every call within the request.
if (\array_key_exists($id, $cache)) {
return $cache[$id]; return $cache[$id];
} }
@@ -706,15 +702,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
private function getArticleDate(int $id, string $field): string private function getArticleDate(int $id, string $field): string
{ {
$article = $this->loadArticle($id); $article = $this->loadArticle($id);
$value = $article->$field ?? '';
// Skip zero/empty dates — emitting "0000-00-00 00:00:00" as return $article->$field ?? '';
// article:published_time/modified_time produces invalid metadata.
if ($value === '' || str_starts_with($value, '0000-00-00')) {
return '';
}
return $value;
} }
/** /**
@@ -831,10 +820,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
*/ */
public function onContentAfterSaveRebuildSitemap(Event $event): void public function onContentAfterSaveRebuildSitemap(Event $event): void
{ {
// Opportunistic maintenance on content save: prune stale generated images
// so the generated-image cache cannot grow without bound.
ImageHelper::pruneOldFiles();
if (!$this->params->get('sitemap_enabled', 0)) { if (!$this->params->get('sitemap_enabled', 0)) {
return; return;
} }
@@ -873,14 +858,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return; return;
} }
// Require article-edit capability — this triggers outbound paid AI calls,
// so it must not be reachable by every authenticated back-end user.
if (!$app->getIdentity()->authorise('core.edit', 'com_content')
&& !$app->getIdentity()->authorise('core.create', 'com_content')) {
$event->setArgument('result', ['Forbidden — insufficient permissions']);
return;
}
if (!$this->params->get('ai_enabled', 0)) { if (!$this->params->get('ai_enabled', 0)) {
$event->setArgument('result', ['AI generation is not enabled']); $event->setArgument('result', ['AI generation is not enabled']);
return; return;
@@ -925,9 +902,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
{ {
$http = \Joomla\CMS\Http\HttpFactory::getHttp(); $http = \Joomla\CMS\Http\HttpFactory::getHttp();
// Cap how long a hung provider can block the admin request.
$timeout = 20;
if ($provider === 'claude') { if ($provider === 'claude') {
$response = $http->post( $response = $http->post(
'https://api.anthropic.com/v1/messages', 'https://api.anthropic.com/v1/messages',
@@ -940,14 +914,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'x-api-key' => $apiKey, 'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01', 'anthropic-version' => '2023-06-01',
], ]
$timeout
); );
if ((int) $response->code !== 200) {
throw new \RuntimeException('Claude API request failed (HTTP ' . (int) $response->code . ')');
}
$data = json_decode($response->body, true); $data = json_decode($response->body, true);
return trim($data['content'][0]['text'] ?? ''); return trim($data['content'][0]['text'] ?? '');
@@ -963,14 +932,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
[ [
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $apiKey, 'Authorization' => 'Bearer ' . $apiKey,
], ]
$timeout
); );
if ((int) $response->code !== 200) {
throw new \RuntimeException('OpenAI API request failed (HTTP ' . (int) $response->code . ')');
}
$data = json_decode($response->body, true); $data = json_decode($response->body, true);
return trim($data['choices'][0]['message']['content'] ?? ''); return trim($data['choices'][0]['message']['content'] ?? '');
@@ -0,0 +1,182 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage plg_system_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\System\MokoOG\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log;
class ImageGenerator
{
private const WIDTH = 1200;
private const HEIGHT = 630;
private const OUTPUT_DIR = 'images/mokoog/generated';
/**
* Generate an OG image with title text overlaid on a template background.
*
* @param string $title Article title to overlay
* @param string $templateImage Path to template/background image relative to JPATH_ROOT
* @param string $fontFile Absolute path to TTF font file
* @param int $fontSize Font size in points (default 42)
* @param array $fontColor RGB array [r, g, b] (default white)
* @param int $quality JPEG quality (default 90)
*
* @return string Path to generated image relative to JPATH_ROOT, or empty on failure
*/
public static function generate(
string $title,
string $templateImage,
string $fontFile = '',
int $fontSize = 42,
array $fontColor = [255, 255, 255],
int $quality = 90
): string {
if (!\extension_loaded('gd')) {
Log::add('MokoOG ImageGenerator: GD extension is not loaded. Image generation disabled.', Log::WARNING, 'mokoog');
return '';
}
$templateAbs = JPATH_ROOT . '/' . ltrim($templateImage, '/');
if (!is_file($templateAbs)) {
Log::add('MokoOG ImageGenerator: Template image not found: ' . $templateImage, Log::WARNING, 'mokoog');
return '';
}
if (!$fontFile || !is_file($fontFile)) {
Log::add('MokoOG ImageGenerator: TTF font file not found: ' . ($fontFile ?: '(not configured)'), Log::WARNING, 'mokoog');
return '';
}
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
Log::add('MokoOG ImageGenerator: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
return '';
}
$hash = md5($title . $templateImage . $fontSize);
$outputName = 'overlay_' . $hash . '.jpg';
$outputPath = $outputDir . '/' . $outputName;
$outputRel = self::OUTPUT_DIR . '/' . $outputName;
// Skip if already generated
if (is_file($outputPath)) {
return $outputRel;
}
// Load template image
$imageInfo = getimagesize($templateAbs);
if (!$imageInfo) {
Log::add('MokoOG ImageGenerator: Cannot read image dimensions: ' . $templateImage, Log::WARNING, 'mokoog');
return '';
}
$source = match ($imageInfo[2]) {
IMAGETYPE_JPEG => imagecreatefromjpeg($templateAbs),
IMAGETYPE_PNG => imagecreatefrompng($templateAbs),
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($templateAbs) : false,
default => false,
};
if (!$source) {
Log::add('MokoOG ImageGenerator: Failed to load image (unsupported type or corrupt): ' . $templateImage, Log::WARNING, 'mokoog');
return '';
}
// Create output canvas at target dimensions
$canvas = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
imagecopyresampled(
$canvas,
$source,
0, 0, 0, 0,
self::WIDTH, self::HEIGHT,
$imageInfo[0], $imageInfo[1]
);
imagedestroy($source);
// Semi-transparent overlay for text readability
$overlay = imagecolorallocatealpha($canvas, 0, 0, 0, 64);
imagefilledrectangle($canvas, 0, (int) (self::HEIGHT * 0.55), self::WIDTH, self::HEIGHT, $overlay);
// Render title text with word wrapping
$textColor = imagecolorallocate($canvas, $fontColor[0], $fontColor[1], $fontColor[2]);
$wrappedTitle = self::wrapText($title, $fontFile, $fontSize, (int) (self::WIDTH * 0.85));
$textX = (int) (self::WIDTH * 0.075);
$textY = (int) (self::HEIGHT * 0.72);
imagettftext($canvas, $fontSize, 0, $textX, $textY, $textColor, $fontFile, $wrappedTitle);
// Save
imagejpeg($canvas, $outputPath, $quality);
imagedestroy($canvas);
return $outputRel;
}
/**
* Wrap text to fit within a maximum pixel width.
*
* @param string $text Text to wrap
* @param string $fontFile Path to TTF font
* @param int $fontSize Font size in points
* @param int $maxWidth Maximum width in pixels
*
* @return string Wrapped text with newlines
*/
private static function wrapText(string $text, string $fontFile, int $fontSize, int $maxWidth): string
{
$words = explode(' ', $text);
$lines = [];
$line = '';
foreach ($words as $word) {
$testLine = $line ? $line . ' ' . $word : $word;
$bbox = imagettfbbox($fontSize, 0, $fontFile, $testLine);
$lineWidth = abs($bbox[4] - $bbox[0]);
if ($lineWidth > $maxWidth && $line !== '') {
$lines[] = $line;
$line = $word;
} else {
$line = $testLine;
}
}
if ($line !== '') {
$lines[] = $line;
}
// Limit to 3 lines, truncate last line if needed
if (\count($lines) > 3) {
$lines = \array_slice($lines, 0, 3);
if (mb_strlen($lines[2]) > 3) {
$lines[2] = mb_substr($lines[2], 0, -3) . '...';
} else {
$lines[2] .= '...';
}
}
return implode("\n", $lines);
}
}
@@ -12,8 +12,8 @@ namespace Joomla\Plugin\System\MokoOG\Helper;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\Filesystem\File; use Joomla\CMS\Filesystem\File;
use Joomla\Filesystem\Folder; use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log; use Joomla\CMS\Log\Log;
class ImageHelper class ImageHelper
@@ -300,39 +300,6 @@ class ImageHelper
} }
} }
/**
* Prune generated images older than the given age, to bound disk usage.
*
* The generated-image cache is never otherwise cleaned, so without this it
* grows unbounded over time.
*
* @param int $maxAgeDays Delete generated files older than this (default 30)
*
* @return void
*/
public static function pruneOldFiles(int $maxAgeDays = 30): void
{
$dir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
if (!is_dir($dir)) {
return;
}
$cutoff = time() - ($maxAgeDays * 86400);
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()
&& $file->getFilename() !== 'index.html'
&& $file->getMTime() < $cutoff) {
File::delete($file->getPathname());
}
}
}
/** /**
* Check if an image meets minimum OG size requirements. * Check if an image meets minimum OG size requirements.
* *
@@ -142,6 +142,23 @@ class JsonLdBuilder
]; ];
} }
/**
* Build Organization schema from site configuration.
*
* @param string $siteName Site name
*
* @return array
*/
public static function buildOrganization(string $siteName): array
{
return [
'@context' => 'https://schema.org',
'@type' => 'Organization',
'name' => $siteName,
'url' => Uri::root(),
];
}
/** /**
* Build Product schema for a MokoSuiteShop product. * Build Product schema for a MokoSuiteShop product.
* *
@@ -37,20 +37,12 @@ class SitemapBuilder
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
// Only include content the public (guest, user id 0) can view — never
// leak registered/special-access articles into the public sitemap.
$publicLevels = array_map('intval', \Joomla\CMS\Access\Access::getAuthorisedViewLevels(0));
// Get all published articles // Get all published articles
$query = $db->getQuery(true) $query = $db->getQuery(true)
->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language'])) ->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language']))
->from($db->quoteName('#__content', 'a')) ->from($db->quoteName('#__content', 'a'))
->where($db->quoteName('a.state') . ' = 1'); ->where($db->quoteName('a.state') . ' = 1');
if (!empty($publicLevels)) {
$query->where($db->quoteName('a.access') . ' IN (' . implode(',', $publicLevels) . ')');
}
$db->setQuery($query); $db->setQuery($query);
$articles = $db->loadObjectList(); $articles = $db->loadObjectList();
@@ -112,19 +104,7 @@ class SitemapBuilder
public static function writeToFile(string $xml): bool public static function writeToFile(string $xml): bool
{ {
$path = JPATH_ROOT . '/sitemap.xml'; $path = JPATH_ROOT . '/sitemap.xml';
$tmp = $path . '.' . uniqid('tmp', true);
if (file_put_contents($tmp, $xml) === false) { return (bool) file_put_contents($path, $xml);
return false;
}
// Atomic replace so concurrent saves never expose a half-written sitemap.
if (!@rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
} }
} }
@@ -8,7 +8,7 @@
--> -->
<extension type="plugin" group="webservices" method="upgrade"> <extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteOpenGraph</name> <name>Web Services - MokoSuiteOpenGraph</name>
<version>01.06.09</version> <version>01.06.00</version>
<creationDate>2026-05-23</creationDate> <creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
+2 -2
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade"> <extension type="package" method="upgrade">
<name>Package - MokoSuiteOpenGraph</name> <name>Package - MokoSuiteOpenGraph</name>
<packagename>mokoog</packagename> <packagename>mokoog</packagename>
<version>01.06.09</version> <version>01.06.00</version>
<creationDate>2026-05-23</creationDate> <creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -31,7 +31,7 @@
</languages> </languages>
<updateservers> <updateservers>
<server type="extension" name="MokoSuiteOpenGraph Updates">https://git.mokoconsulting.tech/api/packages/MokoConsulting/generic/MokoSuiteOpenGraph/latest/updates.xml</server> <server type="extension" name="MokoSuiteOpenGraph Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/updates.xml</server>
</updateservers> </updateservers>
<dlid prefix="dlid=" suffix=""/> <dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall> <blockChildUninstall>true</blockChildUninstall>
@@ -1,93 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage Tests
* @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 Mokoconsulting\MokoOG\Tests\Unit\Helper;
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
use PHPUnit\Framework\TestCase;
class JsonLdBuilderLocalBusinessTest extends TestCase
{
/**
* Minimal Registry-like stand-in exposing get($key, $default).
*/
private function params(array $data): object
{
return new class ($data) {
private array $data;
public function __construct(array $data)
{
$this->data = $data;
}
public function get($key, $default = null)
{
return $this->data[$key] ?? $default;
}
};
}
public function testReturnsNullWithoutName(): void
{
$this->assertNull(JsonLdBuilder::buildLocalBusiness($this->params([])));
$this->assertNull(JsonLdBuilder::buildLocalBusiness($this->params(['lb_name' => ' '])));
}
public function testMinimalSchemaHasNoOptionalKeys(): void
{
$result = JsonLdBuilder::buildLocalBusiness($this->params(['lb_name' => 'Acme Co']));
$this->assertSame('https://schema.org', $result['@context']);
$this->assertSame('LocalBusiness', $result['@type']);
$this->assertSame('Acme Co', $result['name']);
$this->assertArrayNotHasKey('address', $result);
$this->assertArrayNotHasKey('geo', $result);
$this->assertArrayNotHasKey('telephone', $result);
}
public function testCustomTypeAndPartialAddress(): void
{
$result = JsonLdBuilder::buildLocalBusiness($this->params([
'lb_name' => 'Joe Pizza',
'lb_type' => 'Restaurant',
'lb_street' => '1 Main St',
'lb_city' => 'Springfield',
'lb_country' => 'US',
'lb_phone' => '+1-555-0100',
]));
$this->assertSame('Restaurant', $result['@type']);
$this->assertSame('PostalAddress', $result['address']['@type']);
$this->assertSame('1 Main St', $result['address']['streetAddress']);
$this->assertSame('Springfield', $result['address']['addressLocality']);
$this->assertSame('US', $result['address']['addressCountry']);
$this->assertArrayNotHasKey('postalCode', $result['address']);
$this->assertSame('+1-555-0100', $result['telephone']);
}
public function testGeoRequiresBothCoordinates(): void
{
$partial = JsonLdBuilder::buildLocalBusiness($this->params([
'lb_name' => 'X',
'lb_latitude' => '1.0',
]));
$this->assertArrayNotHasKey('geo', $partial);
$full = JsonLdBuilder::buildLocalBusiness($this->params([
'lb_name' => 'X',
'lb_latitude' => '1.0',
'lb_longitude' => '2.0',
]));
$this->assertSame('GeoCoordinates', $full['geo']['@type']);
$this->assertSame('1.0', $full['geo']['latitude']);
$this->assertSame('2.0', $full['geo']['longitude']);
}
}
-56
View File
@@ -1,56 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage Tests
* @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 Mokoconsulting\MokoOG\Tests\Unit\Helper;
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
use PHPUnit\Framework\TestCase;
class JsonLdScriptTagTest extends TestCase
{
private const OPEN = '<script type="application/ld+json">';
private const CLOSE = '</script>';
private function body(string $output): string
{
return substr($output, \strlen(self::OPEN), -\strlen(self::CLOSE));
}
public function testWrapsInLdJsonScriptTag(): void
{
$out = JsonLdBuilder::toScriptTag(['@type' => 'Thing']);
$this->assertStringStartsWith(self::OPEN, $out);
$this->assertStringEndsWith(self::CLOSE, $out);
}
public function testEscapesClosingScriptInsideData(): void
{
$out = JsonLdBuilder::toScriptTag(['name' => 'evil </script><script>alert(1)</script>']);
$body = $this->body($out);
// No raw "</" may survive inside the body — it would let content break out
// of the <script> block. The builder rewrites every "</" to "<\/".
$this->assertStringNotContainsString('</', $body);
$this->assertStringContainsString('<\\/', $body);
}
public function testBodyIsValidJsonAfterUnescaping(): void
{
$out = JsonLdBuilder::toScriptTag(['@context' => 'https://schema.org', '@type' => 'Article']);
$json = str_replace('<\\/', '</', $this->body($out));
$decoded = json_decode($json, true);
$this->assertIsArray($decoded);
$this->assertSame('Article', $decoded['@type']);
$this->assertSame('https://schema.org', $decoded['@context']);
}
}