diff --git a/.mokogitea/CLAUDE.md b/.mokogitea/CLAUDE.md index b4b2470..17d9f70 100644 --- a/.mokogitea/CLAUDE.md +++ b/.mokogitea/CLAUDE.md @@ -7,7 +7,7 @@ Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Per | Field | Value | |---|---| | **Package** | `pkg_mokoog` | -| **Language** | PHP 8.1+ | +| **Language** | PHP 8.3+ | | **Branch** | develop on `dev`, merge to `main` (protected) | | **Wiki** | [MokoSuiteOpenGraph Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/wiki) | @@ -28,7 +28,7 @@ Joomla **package** with three sub-extensions: ### com_mokoog (Component) - Admin backend for viewing/managing all OG tag records -- Joomla 4/5 MVC: `Controller/DisplayController`, `Model/TagsModel`, `View/Tags/HtmlView`, `Table/TagTable` +- Joomla 6 MVC: `Controller/DisplayController`, `Model/TagsModel`, `View/Tags/HtmlView`, `Table/TagTable` - Namespace: `Joomla\Component\MokoOG\Administrator` ### plg_system_mokoog (System Plugin) @@ -58,8 +58,8 @@ Single table `#__mokoog_tags`: ## Coding Standards -- PHP 8.1+ minimum -- Joomla 4/5 DI container pattern: `services/provider.php` → Extension class +- PHP 8.3+ minimum +- Joomla 6 DI container pattern: `services/provider.php` → Extension class - Legacy stub `.php` file required for plugin loader but empty - `SubscriberInterface` for event subscription (not `on*` method naming) - `bind() → check() → store()` for Table operations (not `save()`) diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index eb67f8f..125ecc1 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: MokoGitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# VERSION: 01.07.19 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml index 5fead53..656996f 100644 --- a/.mokogitea/workflows/notify.yml +++ b/.mokogitea/workflows/notify.yml @@ -46,13 +46,13 @@ jobs: WORKFLOW="${{ github.event.workflow_run.name }}" URL="${{ github.event.workflow_run.html_url }}" - curl -sS \ + curl -sS --retry 3 --retry-connrefused --retry-delay 2 --max-time 20 \ -H "Title: ${REPO} released" \ -H "Tags: white_check_mark,package" \ -H "Priority: default" \ -H "Click: ${URL}" \ -d "${WORKFLOW} completed successfully." \ - "${NTFY_URL}/${NTFY_TOPIC}" + "${NTFY_URL}/${NTFY_TOPIC}" || echo "::warning::ntfy notification could not be delivered (non-fatal)" - name: Notify on failure if: github.event.workflow_run.conclusion == 'failure' @@ -61,10 +61,10 @@ jobs: WORKFLOW="${{ github.event.workflow_run.name }}" URL="${{ github.event.workflow_run.html_url }}" - curl -sS \ + curl -sS --retry 3 --retry-connrefused --retry-delay 2 --max-time 20 \ -H "Title: ${REPO} workflow failed" \ -H "Tags: x,warning" \ -H "Priority: high" \ -H "Click: ${URL}" \ -d "${WORKFLOW} failed. Check the run for details." \ - "${NTFY_URL}/${NTFY_TOPIC}" + "${NTFY_URL}/${NTFY_TOPIC}" || echo "::warning::ntfy notification could not be delivered (non-fatal)" diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index c7c2e8f..2874d60 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -258,8 +258,9 @@ jobs: while IFS= read -r -d '' file; do # Skip vendor, node_modules, and index.html stub files case "$file" in ./vendor/*|./node_modules/*) continue ;; esac - # Check first 10 lines for JEXEC or JPATH guard - if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then + # Scan the whole file for the JEXEC/JPATH guard: it is placed after + # the SPDX/file-header docblock, which commonly runs past 20 lines. + if ! grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]" "$file"; then echo "::error file=${file}::Missing JEXEC guard: ${file}" ERRORS=$((ERRORS + 1)) fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7fa65..23cc023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,15 @@ # 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/). - + -## [01.07.00] --- 2026-06-29 +## [Unreleased] ### Added +- **Admin menu short-name constant** — the Administrator → Components menu now shows a compact label via `COM_MOKOOG_SHORT` ("OpenGraph"); defined in the admin sys.ini (#119) - 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) - Single OG tag **create/edit screen** in the admin (the tag manager was previously read-only) (#98) - **CSV import** button and upload form in the tag manager (#103) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 5c99d75..a1ed633 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -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.19 PATH: ./CODE_OF_CONDUCT.md BRIEF: Community expectations and enforcement guidelines NOTE: Adapted with attribution from the Contributor Covenant v2.1 diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 6b00c88..467e551 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -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.19 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for Template-Joomla --> diff --git a/README.md b/README.md index 190c9b2..30fa7a0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoSuiteOpenGraph - + Open Graph, Twitter Card, and social sharing meta tag management for Joomla 6 and higher. @@ -61,7 +61,7 @@ MokoSuiteOpenGraph gives you full control over how your Joomla content appears w ## Installation -**Requirements:** Joomla 6.0 or higher and PHP 8.2 or higher. +**Requirements:** Joomla 6.0 or higher and PHP 8.3 or higher. 1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/releases) 2. In Joomla Administrator → Extensions → Install → Upload Package File diff --git a/SECURITY.md b/SECURITY.md index efc1e5f..051757e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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.19 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/composer.json b/composer.json index a5fd414..9bd5a31 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ } ], "require": { - "php": ">=8.1" + "php": ">=8.3" }, "require-dev": { "joomla/coding-standards": "^3.0", diff --git a/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini b/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini index 0e382dd..49acd54 100644 --- a/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini +++ b/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini @@ -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." diff --git a/source/packages/com_mokoog/mokoog.xml b/source/packages/com_mokoog/mokoog.xml index b1e648c..467ae44 100644 --- a/source/packages/com_mokoog/mokoog.xml +++ b/source/packages/com_mokoog/mokoog.xml @@ -8,7 +8,7 @@ --> com_mokoog - 01.07.00 + 01.07.19 2026-05-23 Moko Consulting hello@mokoconsulting.tech @@ -71,7 +71,7 @@ access.xml config.xml - COM_MOKOOG + COM_MOKOOG_SHORT COM_MOKOOG_SUBMENU_DASHBOARD COM_MOKOOG_SUBMENU_TAGS diff --git a/source/packages/com_mokoog/script.php b/source/packages/com_mokoog/script.php index ace5636..75ba03a 100644 --- a/source/packages/com_mokoog/script.php +++ b/source/packages/com_mokoog/script.php @@ -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 '

MokoSuiteOpenGraph component installed successfully.

'; + // 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( + 'MokoSuiteOpenGraph did not install correctly. ' . $detail + . ' — review the errors above, then reinstall.', + 'error' + ); + + return; + } + + Factory::getApplication()->enqueueMessage('

MokoSuiteOpenGraph installed successfully!

', 'info'); } /** @@ -35,6 +55,65 @@ class Com_MokoOGInstallerScript */ public function update(InstallerAdapter $parent): void { - echo '

MokoSuiteOpenGraph component updated successfully.

'; + // 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( + 'MokoSuiteOpenGraph did not update correctly. ' . $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 []; + } } } diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.01.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.01.sql new file mode 100644 index 0000000..91983b0 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.01.sql @@ -0,0 +1 @@ +/* 01.07.01 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.02.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.02.sql new file mode 100644 index 0000000..393d9b9 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.02.sql @@ -0,0 +1 @@ +/* 01.07.02 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.03.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.03.sql new file mode 100644 index 0000000..b353b72 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.03.sql @@ -0,0 +1 @@ +/* 01.07.03 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.05.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.05.sql new file mode 100644 index 0000000..6c1be34 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.05.sql @@ -0,0 +1 @@ +/* 01.07.05 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.07.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.07.sql new file mode 100644 index 0000000..8f086aa --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.07.sql @@ -0,0 +1 @@ +/* 01.07.07 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.08.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.08.sql new file mode 100644 index 0000000..0921fce --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.08.sql @@ -0,0 +1 @@ +/* 01.07.08 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.09.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.09.sql new file mode 100644 index 0000000..521edc5 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.09.sql @@ -0,0 +1 @@ +/* 01.07.09 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.10.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.10.sql new file mode 100644 index 0000000..5eb421c --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.10.sql @@ -0,0 +1 @@ +/* 01.07.10 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.11.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.11.sql new file mode 100644 index 0000000..35338ef --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.11.sql @@ -0,0 +1 @@ +/* 01.07.11 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.12.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.12.sql new file mode 100644 index 0000000..a3f268b --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.12.sql @@ -0,0 +1 @@ +/* 01.07.12 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.13.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.13.sql new file mode 100644 index 0000000..7088fa4 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.13.sql @@ -0,0 +1 @@ +/* 01.07.13 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.14.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.14.sql new file mode 100644 index 0000000..d52374c --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.14.sql @@ -0,0 +1 @@ +/* 01.07.14 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.15.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.15.sql new file mode 100644 index 0000000..e0bdac3 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.15.sql @@ -0,0 +1 @@ +/* 01.07.15 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.16.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.16.sql new file mode 100644 index 0000000..e9d61ac --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.16.sql @@ -0,0 +1 @@ +/* 01.07.16 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.17.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.17.sql new file mode 100644 index 0000000..5e4a8fa --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.17.sql @@ -0,0 +1 @@ +/* 01.07.17 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.18.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.18.sql new file mode 100644 index 0000000..1d3b2b6 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.18.sql @@ -0,0 +1 @@ +/* 01.07.18 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.07.19.sql b/source/packages/com_mokoog/sql/updates/mysql/01.07.19.sql new file mode 100644 index 0000000..9e9a677 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.07.19.sql @@ -0,0 +1 @@ +/* 01.07.19 — no schema changes */ diff --git a/source/packages/com_mokoog/src/Controller/BatchController.php b/source/packages/com_mokoog/src/Controller/BatchController.php index ee2aff8..2031b4e 100644 --- a/source/packages/com_mokoog/src/Controller/BatchController.php +++ b/source/packages/com_mokoog/src/Controller/BatchController.php @@ -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; - $now = Factory::getDate()->toSql(); + $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); @@ -131,7 +140,10 @@ class BatchController extends BaseController } echo new JsonResponse([ - 'created' => $created, + 'created' => $created, + 'skipped' => $skipped, + 'examined' => \count($articles), + 'last_id' => $lastProcessedId, ]); $app->close(); diff --git a/source/packages/com_mokoog/tmpl/tags/default.php b/source/packages/com_mokoog/tmpl/tags/default.php index 77e8c56..82143a4 100644 --- a/source/packages/com_mokoog/tmpl/tags/default.php +++ b/source/packages/com_mokoog/tmpl/tags/default.php @@ -234,27 +234,31 @@ document.addEventListener('DOMContentLoaded', function() { return; } status.textContent = total + ' '; - processChunk(0, total, chunkSize, token, bar, status); + processChunk(0, 0, total, chunkSize, token, bar, status); }) .catch(function(err) { status.textContent = ' ' + 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 + ' '; - 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 = ' ' + processed + ' articles.'; diff --git a/source/packages/plg_content_mokoog/mokoog.xml b/source/packages/plg_content_mokoog/mokoog.xml index a91e6de..edda11f 100644 --- a/source/packages/plg_content_mokoog/mokoog.xml +++ b/source/packages/plg_content_mokoog/mokoog.xml @@ -8,7 +8,7 @@ --> Content - MokoSuiteOpenGraph - 01.07.00 + 01.07.19 2026-05-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_system_mokoog/mokoog.xml b/source/packages/plg_system_mokoog/mokoog.xml index f0f5175..590de71 100644 --- a/source/packages/plg_system_mokoog/mokoog.xml +++ b/source/packages/plg_system_mokoog/mokoog.xml @@ -8,7 +8,7 @@ --> System - MokoSuiteOpenGraph - 01.07.00 + 01.07.19 2026-05-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php index 38d661c..c625448 100644 --- a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php +++ b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php @@ -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); diff --git a/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php index c8f4e65..a443318 100644 --- a/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php +++ b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php @@ -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. * diff --git a/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php b/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php index 45b4040..6b7df61 100644 --- a/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php +++ b/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php @@ -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. * diff --git a/source/packages/plg_webservices_mokoog/mokoog.xml b/source/packages/plg_webservices_mokoog/mokoog.xml index 75e5670..051b2a8 100644 --- a/source/packages/plg_webservices_mokoog/mokoog.xml +++ b/source/packages/plg_webservices_mokoog/mokoog.xml @@ -8,7 +8,7 @@ --> Web Services - MokoSuiteOpenGraph - 01.07.00 + 01.07.19 2026-05-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/pkg_mokoog.xml b/source/pkg_mokoog.xml index 390bf1c..4e15347 100644 --- a/source/pkg_mokoog.xml +++ b/source/pkg_mokoog.xml @@ -8,7 +8,7 @@ Package - MokoSuiteOpenGraph mokoog - 01.07.00 + 01.07.19 2026-05-23 Moko Consulting hello@mokoconsulting.tech diff --git a/source/script.php b/source/script.php index c90d65e..5ab47da 100644 --- a/source/script.php +++ b/source/script.php @@ -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( + 'MokoSuiteOpenGraph did not install completely. ' + . '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; } diff --git a/tests/Unit/Helper/JsonLdBuilderLocalBusinessTest.php b/tests/Unit/Helper/JsonLdBuilderLocalBusinessTest.php new file mode 100644 index 0000000..be1de69 --- /dev/null +++ b/tests/Unit/Helper/JsonLdBuilderLocalBusinessTest.php @@ -0,0 +1,93 @@ + + * @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']); + } +} diff --git a/tests/Unit/Helper/JsonLdScriptTagTest.php b/tests/Unit/Helper/JsonLdScriptTagTest.php new file mode 100644 index 0000000..757e484 --- /dev/null +++ b/tests/Unit/Helper/JsonLdScriptTagTest.php @@ -0,0 +1,56 @@ + + * @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 = ''; + + 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 ']); + $body = $this->body($out); + + // No raw " block. The builder rewrites every "assertStringNotContainsString('assertStringContainsString('<\\/', $body); + } + + public function testBodyIsValidJsonAfterUnescaping(): void + { + $out = JsonLdBuilder::toScriptTag(['@context' => 'https://schema.org', '@type' => 'Article']); + $json = str_replace('<\\/', 'body($out)); + + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertSame('Article', $decoded['@type']); + $this->assertSame('https://schema.org', $decoded['@context']); + } +}