Compare commits

..

25 Commits

Author SHA1 Message Date
mokogitea-actions[bot] ddfec7b8c2 chore(version): pre-release bump to 01.07.12-dev [skip ci] 2026-07-09 23:23:34 +00:00
jmiller 50d56b2bf6 Merge pull request 'chore(sync): cascade main -> dev (conflict-resolved)' (#124) from chore/cascade-main-to-dev into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
2026-07-09 23:23:17 +00:00
jmiller 419e333f20 Merge remote-tracking branch 'origin/main' into chore/cascade-main-to-dev
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Generic: Project CI / Lint & Validate (pull_request) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
Generic: Project CI / Tests (pull_request) Failing after 26s
Universal: PR Check / Validate PR (pull_request) Successful in 32s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 32s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
# Conflicts:
#	.mokogitea/workflows/issue-branch.yml
#	CHANGELOG.md
#	CODE_OF_CONDUCT.md
#	GOVERNANCE.md
#	README.md
#	SECURITY.md
#	source/packages/com_mokoog/mokoog.xml
#	source/packages/plg_content_mokoog/mokoog.xml
#	source/packages/plg_system_mokoog/mokoog.xml
#	source/packages/plg_webservices_mokoog/mokoog.xml
#	source/pkg_mokoog.xml
2026-07-09 18:22:49 -05:00
gitea-actions[bot] b6e9afacd8 chore(version): pre-release bump to 01.07.11-dev [skip ci] 2026-07-09 23:11:21 +00:00
jmiller 1e054c9864 Merge pull request 'test: unit tests for pure JsonLdBuilder methods (#33 partial)' (#113) from test/jsonld-unit-33 into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
2026-07-09 23:11:11 +00:00
gitea-actions[bot] eac29f3f23 chore(version): pre-release bump to 01.07.10-dev [skip ci] 2026-07-07 10:16:42 +00:00
jmiller 89830842a8 Merge pull request 'feat(menu): admin menu short-name constant COM_MOKOOG_SHORT (#119)' (#123) from fix/short-menu-constant into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 10s
2026-07-07 10:15:23 +00:00
gitea-actions[bot] a2ba10c562 chore(version): pre-release bump to 01.07.09-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
2026-07-06 22:41:46 +00:00
gitea-actions[bot] e94a950cb0 chore(version): pre-release bump to 01.07.08-dev [skip ci] 2026-07-06 22:40:10 +00:00
gitea-actions[bot] c64c503f5d chore(version): pre-release bump to 01.07.07-dev [skip ci] 2026-07-06 22:39:54 +00:00
jmiller 2b56dc411a feat(menu): use COM_MOKOOG_SHORT for admin menu label (#119)
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Project CI / Lint & Validate (pull_request) Successful in 16s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Claude-Session: https://claude.ai/code/session_01B9aZHSWbiiZykJD88pYx8R
2026-07-06 22:37:44 +00:00
jmiller 6ef67d9ca7 feat(menu): add COM_MOKOOG_SHORT short-name constant (#119)
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
Claude-Session: https://claude.ai/code/session_01B9aZHSWbiiZykJD88pYx8R
2026-07-06 22:37:10 +00:00
jmiller cf744a8bd2 Merge pull request 'fix(installer): gate update() + enqueueMessage for success (og-com tidy)' (#122) from fix/og-installer-tidy into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s
2026-07-06 16:24:41 +00:00
jmiller dbaeb33dc4 fix(installer): gate update() on schema + use enqueueMessage for success
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
Generic: Project CI / Lint & Validate (pull_request) Successful in 27s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Mirrors the install() smart-check on update() and replaces echo with a proper
Joomla message for consistency.

Claude-Session: https://claude.ai/code/session_01MrxXW9iyuDda5yDZrjZMbF
2026-07-06 16:24:15 +00:00
jmiller fe38a422be Merge pull request 'fix(installer): gate install-success on real verification' (#120) from fix/installer-success-detection into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
2026-07-06 16:15:36 +00:00
gitea-actions[bot] a6fe8fb580 chore(version): pre-release bump to 01.07.05-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
2026-07-06 03:55:02 +00:00
jmiller a6a24f72de fix(installer): gate install-success on real verification
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
Joomla installers keep running after a failed sub-step, so a postflight
"installed successfully" / next-steps message can lie. Gate the success
path in both installer scripts.

Package (source/script.php): postflight now verifies every child extension
declared in the package manifest actually registered in #__extensions
(element + type, plus folder for plugins). If any are missing it enqueues
an error listing them and returns before the licence notice. Download-key
restore and plugin-enable housekeeping still run unconditionally. Check
fails open on any error.

Component (source/packages/com_mokoog/script.php): install() now parses the
installed SQL (JPATH_ADMINISTRATOR/components/com_mokoog/sql/install.mysql.sql)
for CREATE TABLE declarations and verifies each exists (prefix-substituted,
case-insensitive) before echoing the success message. Fails open if the SQL
is unreadable.

Claude-Session: https://claude.ai/code/session_01MrxXW9iyuDda5yDZrjZMbF
2026-07-05 22:54:38 -05:00
gitea-actions[bot] 84b3d9dc2c chore(version): pre-release bump to 01.07.03-dev [skip ci] 2026-06-29 17:26:56 +00:00
jmiller d6d93bddd9 Merge pull request 'refactor: consolidate ImageHelper resize paths + drop unused validate() (#104)' (#117) from fix/imagehelper-consolidate-104 into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 9s
2026-06-29 17:26:45 +00:00
gitea-actions[bot] 3e6c8d685b chore(version): pre-release bump to 01.07.02-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
2026-06-29 17:26:29 +00:00
jmiller 236baed80d refactor: consolidate ImageHelper resize paths, drop unused validate() (#104)
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
- resize() is now a thin wrapper over resizeToSize() (added a $quality param),
  removing ~80 lines of duplicated crop/resample/encode logic. resize(path)
  and resizeToSize(path, 1200, 630, '') produce identical output paths/files,
  so behavior is unchanged.
- Removed the unused ImageHelper::validate() method (no callers).

Net: -130/+11 lines. Closes the remaining #104 consolidation items.
2026-06-29 12:26:13 -05:00
gitea-actions[bot] fd28cb8a93 chore(version): pre-release bump to 01.07.01-dev [skip ci] 2026-06-29 17:18:29 +00:00
jmiller cb0e5596ea Merge pull request 'refactor: sitemap throttle + SEF URLs (#100) and batch cursor pagination (#106)' (#116) from feat/sitemap-batch-redesign into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
2026-06-29 17:18:11 +00:00
jmiller 7532446e46 refactor: sitemap throttle + SEF URLs (#100) and batch cursor pagination (#106)
Generic: Project CI / Lint & Validate (pull_request) Successful in 13s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 10s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
#106 — BatchController now paginates by id cursor (WHERE c.id > :lastId)
instead of always querying offset 0. A row that fails to insert falls behind
the cursor and is not re-fetched, so the batch always terminates and reaches
100% even with persistent failures. process() returns examined + last_id; the
editor JS drives the cursor and stops when a chunk examines 0 rows.

#100 — Sitemap:
- Throttle: regenerate at most once per 60s on content save (SITEMAP_MIN_INTERVAL)
  so bulk edits/imports don't rebuild the whole file every save
- SEF URLs: route each article via Route::link('site', ...) with a fallback to
  the non-SEF index.php URL if routing fails (worst case = prior behavior)
(access-level filtering + atomic write were done earlier in the cycle)
2026-06-29 12:17:37 -05:00
jmiller 903d4d37c8 test: add unit tests for pure JsonLdBuilder methods (#33 partial)
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Generic: Project CI / Lint & Validate (pull_request) Successful in 11s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Failing after 28s
Generic: Project CI / Tests (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
- JsonLdBuilderLocalBusinessTest: buildLocalBusiness() null/minimal/custom-type/
  partial-address/geo-requires-both-coords cases
- JsonLdScriptTagTest: toScriptTag() wraps in ld+json, escapes </ to prevent
  </script> breakout, and produces valid JSON after unescaping

Covers the pure (Factory/Uri/DB-free) helpers that run under the existing
minimal bootstrap. The remaining #33 targets (ImageHelper, BatchController,
CSV import, content/system plugin integration) need a Joomla test harness.
2026-06-29 10:52:55 -05:00
31 changed files with 443 additions and 167 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: MokoGitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.00.00
# VERSION: 01.07.12
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+2 -7
View File
@@ -1,17 +1,12 @@
# Changelog
## [Unreleased]
## [01.07.00] --- 2026-06-29
All notable changes to MokoSuiteOpenGraph will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
<!-- VERSION: 01.07.00 -->
<!-- VERSION: 01.07.12 -->
## [01.07.00] --- 2026-06-29
## [Unreleased]
### Added
- OG coverage **dashboard** as the default admin view — SVG donut gauge, coverage by content type, and a list of articles missing OG tags with a batch-generate shortcut (#94)
+1 -1
View File
@@ -14,7 +14,7 @@
DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
VERSION: 01.07.00
VERSION: 01.07.12
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Community expectations and enforcement guidelines
NOTE: Adapted with attribution from the Contributor Covenant v2.1
+1 -1
View File
@@ -19,7 +19,7 @@
DEFGROUP: mokoconsulting-tech.Template-Joomla
INGROUP: MokoStandards.Governance
REPO: https://github.com/mokoconsulting-tech/Template-Joomla
VERSION: 01.07.00
VERSION: 01.07.12
PATH: /GOVERNANCE.md
BRIEF: Project governance rules, roles, and decision process for Template-Joomla
-->
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteOpenGraph
<!-- VERSION: 01.07.00 -->
<!-- VERSION: 01.07.12 -->
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
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md
VERSION: 01.07.00
VERSION: 01.07.12
BRIEF: Security vulnerability reporting and handling policy
-->
@@ -3,4 +3,5 @@
; License: GPL-3.0-or-later
COM_MOKOOG="MokoSuiteOpenGraph"
COM_MOKOOG_SHORT="OpenGraph"
COM_MOKOOG_DESCRIPTION="Manage Open Graph and social sharing tags for all your content. View, edit, and batch-process OG metadata."
+2 -2
View File
@@ -8,7 +8,7 @@
-->
<extension type="component" method="upgrade">
<name>com_mokoog</name>
<version>01.07.00</version>
<version>01.07.12</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -71,7 +71,7 @@
<filename>access.xml</filename>
<filename>config.xml</filename>
</files>
<menu img="class:bookmark">COM_MOKOOG</menu>
<menu img="class:bookmark">COM_MOKOOG_SHORT</menu>
<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>
+81 -2
View File
@@ -10,6 +10,7 @@
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Installer\InstallerAdapter;
class Com_MokoOGInstallerScript
@@ -23,7 +24,26 @@ class Com_MokoOGInstallerScript
*/
public function install(InstallerAdapter $parent): void
{
echo '<p>MokoSuiteOpenGraph component installed successfully.</p>';
// Be smart: verify EVERY table the component's own install SQL declares is
// actually present. If any are missing the install did not complete — say
// so instead of showing "installed successfully".
$missing = $this->missingTables();
if ($missing !== []) {
$detail = count($missing) > 6
? count($missing) . ' expected database tables are missing'
: 'missing tables: ' . htmlspecialchars(implode(', ', $missing), ENT_QUOTES, 'UTF-8');
Factory::getApplication()->enqueueMessage(
'<strong>MokoSuiteOpenGraph did not install correctly.</strong> ' . $detail
. ' — review the errors above, then reinstall.',
'error'
);
return;
}
Factory::getApplication()->enqueueMessage('<h4>MokoSuiteOpenGraph installed successfully!</h4>', 'info');
}
/**
@@ -35,6 +55,65 @@ class Com_MokoOGInstallerScript
*/
public function update(InstallerAdapter $parent): void
{
echo '<p>MokoSuiteOpenGraph component updated successfully.</p>';
// Same smart check on update: only report success if the schema is intact.
$missing = $this->missingTables();
if ($missing !== []) {
$detail = count($missing) > 6
? count($missing) . ' expected database tables are missing'
: 'missing tables: ' . htmlspecialchars(implode(', ', $missing), ENT_QUOTES, 'UTF-8');
Factory::getApplication()->enqueueMessage(
'<strong>MokoSuiteOpenGraph did not update correctly.</strong> ' . $detail
. ' — review the errors above.',
'error'
);
return;
}
Factory::getApplication()->enqueueMessage('MokoSuiteOpenGraph updated successfully.', 'info');
}
/**
* Smart schema check: parse the component's own installed install SQL for every
* CREATE TABLE, then return the ones missing from the database. An empty array
* means the full schema installed. Fails open (returns []) if the SQL can't be
* read, so a good install is never flagged as failed.
*
* @return string[] Names (without the #__ prefix) of missing tables.
*/
private function missingTables(): array
{
try {
$sqlFile = JPATH_ADMINISTRATOR . '/components/com_mokoog/sql/install.mysql.sql';
if (!is_file($sqlFile)) {
return [];
}
$sql = file_get_contents($sqlFile);
if ($sql === false
|| !preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\']?(#__[A-Za-z0-9_]+)/i', $sql, $m)) {
return [];
}
$db = Factory::getDbo();
$prefix = strtolower($db->getPrefix());
$have = array_map('strtolower', $db->getTableList());
$missing = [];
foreach (array_unique($m[1]) as $decl) {
$bare = substr($decl, 3); // strip "#__"
if (!in_array($prefix . strtolower($bare), $have, true)) {
$missing[] = $bare;
}
}
return $missing;
} catch (\Throwable $e) {
return [];
}
}
}
@@ -0,0 +1 @@
/* 01.07.01 — no schema changes */
@@ -0,0 +1 @@
/* 01.07.02 — no schema changes */
@@ -0,0 +1 @@
/* 01.07.03 — no schema changes */
@@ -0,0 +1 @@
/* 01.07.05 — no schema changes */
@@ -0,0 +1 @@
/* 01.07.07 — no schema changes */
@@ -0,0 +1 @@
/* 01.07.08 — no schema changes */
@@ -0,0 +1 @@
/* 01.07.09 — no schema changes */
@@ -0,0 +1 @@
/* 01.07.10 — no schema changes */
@@ -0,0 +1 @@
/* 01.07.11 — no schema changes */
@@ -0,0 +1 @@
/* 01.07.12 — no schema changes */
@@ -73,7 +73,9 @@ class BatchController extends BaseController
}
$app = Factory::getApplication();
$limit = min($app->getInput()->getInt('limit', 50), 200);
$input = $app->getInput();
$limit = min($input->getInt('limit', 50), 200);
$lastId = max(0, $input->getInt('lastid', 0));
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$query = $db->getQuery(true)
@@ -88,18 +90,25 @@ class BatchController extends BaseController
)
->where($db->quoteName('c.state') . ' = 1')
->where($db->quoteName('t.id') . ' IS NULL')
->where($db->quoteName('c.id') . ' > ' . $lastId)
->order($db->quoteName('c.id') . ' ASC');
// Always offset=0: processed articles now have #__mokoog_tags rows
// and are excluded by the LEFT JOIN ... IS NULL filter automatically.
// Cursor-based pagination by id: each chunk fetches the next articles whose
// id is greater than the previous chunk's highest id. A row that fails to
// insert is passed over on the next chunk (its id is already behind the
// cursor) instead of being re-fetched forever, so the batch always reaches
// the end. The client stops when a chunk examines 0 rows.
$db->setQuery($query, 0, $limit);
$articles = $db->loadObjectList();
$created = 0;
$skipped = 0;
$lastProcessedId = $lastId;
$now = Factory::getDate()->toSql();
foreach ($articles as $article) {
$lastProcessedId = (int) $article->id;
$ogTitle = $article->title;
$ogDescription = $this->extractDescription($article);
$ogImage = $this->extractImage($article);
@@ -132,6 +141,9 @@ class BatchController extends BaseController
echo new JsonResponse([
'created' => $created,
'skipped' => $skipped,
'examined' => \count($articles),
'last_id' => $lastProcessedId,
]);
$app->close();
@@ -234,27 +234,31 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
status.textContent = total + ' <?php echo Text::_('COM_MOKOOG_BATCH_FOUND', true); ?>';
processChunk(0, total, chunkSize, token, bar, status);
processChunk(0, 0, total, chunkSize, token, bar, status);
})
.catch(function(err) {
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message;
});
}
function processChunk(processed, total, chunkSize, token, bar, status) {
// Always offset=0: processed items are excluded by the IS NULL filter
fetch('index.php?option=com_mokoog&task=batch.process&format=json&limit=' + chunkSize + '&' + token + '=1')
function processChunk(lastId, processed, total, chunkSize, token, bar, status) {
// Cursor-based: pass the highest id seen so far. Failed rows fall behind
// the cursor and are not re-fetched, so the loop always terminates.
fetch('index.php?option=com_mokoog&task=batch.process&format=json&limit=' + chunkSize + '&lastid=' + lastId + '&' + token + '=1')
.then(function(r) { return r.json(); })
.then(function(resp) {
processed += resp.data.created;
var pct = Math.min(100, Math.round((processed / total) * 100));
var examined = resp.data.examined || 0;
processed += examined;
var pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 100;
bar.style.width = pct + '%';
bar.textContent = pct + '%';
status.textContent = processed + ' / ' + total + ' <?php echo Text::_('COM_MOKOOG_BATCH_PROCESSED', true); ?>';
if (resp.data.created > 0 && processed < total) {
processChunk(processed, total, chunkSize, token, bar, status);
if (examined > 0) {
processChunk(resp.data.last_id, processed, total, chunkSize, token, bar, status);
} else {
bar.style.width = '100%';
bar.textContent = '100%';
bar.classList.remove('progress-bar-animated');
bar.classList.add('bg-success');
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COMPLETE', true); ?> ' + processed + ' articles.';
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteOpenGraph</name>
<version>01.07.00</version>
<version>01.07.12</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteOpenGraph</name>
<version>01.07.00</version>
<version>01.07.12</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -28,6 +28,11 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
*/
protected $autoloadLanguage = true;
/**
* Minimum seconds between full sitemap regenerations (save-time throttle).
*/
private const SITEMAP_MIN_INTERVAL = 60;
/**
* Returns the events this plugin subscribes to.
*
@@ -845,6 +850,15 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return;
}
// Throttle: rebuilding the whole sitemap on every save does not scale
// (bulk edits/imports). Regenerate at most once per interval — the
// sitemap is eventually consistent within that window.
$path = JPATH_ROOT . '/sitemap.xml';
if (is_file($path) && (time() - filemtime($path)) < self::SITEMAP_MIN_INTERVAL) {
return;
}
$changefreq = $this->params->get('sitemap_changefreq', 'weekly');
$xml = SitemapBuilder::generate($changefreq);
@@ -57,96 +57,8 @@ class ImageHelper
int $targetHeight = self::TARGET_HEIGHT,
int $quality = self::JPEG_QUALITY
): string {
// Resolve absolute path
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
if (!is_file($absPath)) {
return $imagePath;
}
$imageInfo = getimagesize($absPath);
if (!$imageInfo) {
Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog');
return $imagePath;
}
[$origWidth, $origHeight, $type] = $imageInfo;
// Skip if already at or below target size
if ($origWidth <= $targetWidth && $origHeight <= $targetHeight) {
return $imagePath;
}
// Ensure output directory exists
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
return $imagePath;
}
// Generate output filename based on source hash + dimensions
$hash = md5($imagePath . $targetWidth . $targetHeight);
$outputName = $hash . '.jpg';
$outputPath = $outputDir . '/' . $outputName;
$outputRel = self::OUTPUT_DIR . '/' . $outputName;
// Skip if already generated
if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) {
return $outputRel;
}
// Load source image
$source = self::loadImage($absPath, $type);
if (!$source) {
return $imagePath;
}
// Calculate crop dimensions (center crop to target aspect ratio)
$targetRatio = $targetWidth / $targetHeight;
$sourceRatio = $origWidth / $origHeight;
if ($sourceRatio > $targetRatio) {
// Source is wider — crop sides
$cropHeight = $origHeight;
$cropWidth = (int) round($origHeight * $targetRatio);
$cropX = (int) round(($origWidth - $cropWidth) / 2);
$cropY = 0;
} else {
// Source is taller — crop top/bottom
$cropWidth = $origWidth;
$cropHeight = (int) round($origWidth / $targetRatio);
$cropX = 0;
$cropY = (int) round(($origHeight - $cropHeight) / 2);
}
// Create output canvas and resample
$output = imagecreatetruecolor($targetWidth, $targetHeight);
imagecopyresampled(
$output,
$source,
0,
0,
$cropX,
$cropY,
$targetWidth,
$targetHeight,
$cropWidth,
$cropHeight
);
// Save as JPEG
imagejpeg($output, $outputPath, $quality);
imagedestroy($source);
imagedestroy($output);
return $outputRel;
// Thin wrapper over the shared implementation (no subdirectory).
return self::resizeToSize($imagePath, $targetWidth, $targetHeight, '', $quality);
}
/**
@@ -182,11 +94,17 @@ class ImageHelper
* @param int $width Target width
* @param int $height Target height
* @param string $subdir Subdirectory name for output (e.g. platform name)
* @param int $quality JPEG quality 1-100
*
* @return string Path to the output image (relative to JPATH_ROOT)
*/
private static function resizeToSize(string $imagePath, int $width, int $height, string $subdir = ''): string
{
private static function resizeToSize(
string $imagePath,
int $width,
int $height,
string $subdir = '',
int $quality = self::JPEG_QUALITY
): string {
// Resolve absolute path
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
@@ -272,7 +190,7 @@ class ImageHelper
);
// Save as JPEG
imagejpeg($output, $outputPath, self::JPEG_QUALITY);
imagejpeg($output, $outputPath, $quality);
imagedestroy($source);
imagedestroy($output);
@@ -333,43 +251,6 @@ class ImageHelper
}
}
/**
* Check if an image meets minimum OG size requirements.
*
* @param string $imagePath Image path relative to JPATH_ROOT
*
* @return array{valid: bool, width: int, height: int, message: string}
*/
public static function validate(string $imagePath): array
{
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
if (!is_file($absPath)) {
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'File not found'];
}
$imageInfo = getimagesize($absPath);
if (!$imageInfo) {
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'Not a valid image'];
}
[$width, $height] = $imageInfo;
// Facebook minimum: 200x200, recommended: 1200x630
// WhatsApp minimum: 300x200
if ($width < 200 || $height < 200) {
return [
'valid' => false,
'width' => $width,
'height' => $height,
'message' => "Image too small ({$width}x{$height}). Minimum: 200x200px.",
];
}
return ['valid' => true, 'width' => $width, 'height' => $height, 'message' => 'OK'];
}
/**
* Load an image resource from a file.
*
@@ -81,7 +81,7 @@ class SitemapBuilder
continue;
}
$url = $root . '/index.php?option=com_content&view=article&id=' . $article->id;
$url = self::articleUrl($article, $root);
$lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00'
? date('Y-m-d', strtotime($article->modified)) : '';
@@ -102,6 +102,45 @@ class SitemapBuilder
return $xml;
}
/**
* Build the SEF/canonical site URL for an article, with a safe fallback.
*
* Routes through the site router so the sitemap matches the canonical URLs
* the plugin emits. If routing fails (or SEF is off), falls back to the
* non-SEF index.php URL — never an empty or broken URL.
*
* @param object $article Row with id, alias, catid, language
* @param string $root Site root without trailing slash
*
* @return string Absolute URL
*/
private static function articleUrl(object $article, string $root): string
{
$fallback = $root . '/index.php?option=com_content&view=article&id=' . (int) $article->id;
$internal = 'index.php?option=com_content&view=article&id=' . (int) $article->id
. (!empty($article->alias) ? ':' . $article->alias : '')
. (!empty($article->catid) ? '&catid=' . (int) $article->catid : '');
try {
$routed = \Joomla\CMS\Router\Route::link(
'site',
$internal,
false,
\Joomla\CMS\Router\Route::TLS_IGNORE,
true
);
if (\is_string($routed) && $routed !== '') {
return $routed;
}
} catch (\Throwable $e) {
// Fall back to the non-SEF URL below.
}
return $fallback;
}
/**
* Write sitemap XML to the site root.
*
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteOpenGraph</name>
<version>01.07.00</version>
<version>01.07.12</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteOpenGraph</name>
<packagename>mokoog</packagename>
<version>01.07.00</version>
<version>01.07.12</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+93 -1
View File
@@ -50,9 +50,10 @@ class Pkg_MokoOGInstallerScript
public function postflight(string $type, InstallerAdapter $parent): void
{
// The download key must be restored regardless of the install outcome.
$this->restoreDownloadKey();
$this->warnMissingLicenseKey();
// Housekeeping: ensure the bundled plugins are enabled after a fresh install.
if ($type === 'install')
{
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
@@ -69,6 +70,97 @@ class Pkg_MokoOGInstallerScript
)->execute();
}
}
// Be smart: only run the success path (the licence notice, which implies a
// completed install) when every child extension declared in the package
// manifest actually registered. If any are missing, the package did NOT
// install successfully — surface that instead of implying success.
$missing = $this->missingChildExtensions($parent);
if ($missing !== [])
{
Factory::getApplication()->enqueueMessage(
'<strong>MokoSuiteOpenGraph did not install completely.</strong> '
. 'The following extension(s) failed to install: ' . htmlspecialchars(implode(', ', $missing), ENT_QUOTES, 'UTF-8') . '. '
. 'Resolve the errors shown above, then uninstall and reinstall the package.',
'error'
);
return;
}
$this->warnMissingLicenseKey();
}
/**
* Compare the child extensions the package manifest declares against what is
* actually registered in #__extensions after the install/update. Returns the
* label of each declared extension that is missing (i.e. failed to install);
* an empty array means every child installed and the package succeeded.
*
* Fails open: any error returns [] so a query problem can never turn a good
* install into a false "did not install completely" warning.
*
* @param InstallerAdapter $parent Installer adapter
*
* @return string[]
*/
private function missingChildExtensions(InstallerAdapter $parent): array
{
$missing = [];
try
{
$manifest = $parent->getParent()->getManifest();
if (!$manifest || !isset($manifest->files) || !isset($manifest->files->file))
{
return [];
}
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
foreach ($manifest->files->file as $file)
{
$childType = (string) $file['type'];
$element = (string) $file['id'];
$group = (string) $file['group'];
if ($element === '')
{
continue;
}
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote($element));
if ($childType !== '')
{
$query->where($db->quoteName('type') . ' = ' . $db->quote($childType));
}
// Plugins are only unique by element + folder (the manifest "group").
if ($childType === 'plugin' && $group !== '')
{
$query->where($db->quoteName('folder') . ' = ' . $db->quote($group));
}
$db->setQuery($query);
if ((int) $db->loadResult() === 0)
{
$missing[] = $element . ($group !== '' ? ' (' . $group . ')' : '');
}
}
}
catch (\Throwable $e)
{
return [];
}
return $missing;
}
@@ -0,0 +1,93 @@
<?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
@@ -0,0 +1,56 @@
<?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']);
}
}