Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions[bot] df99f2499e chore(version): pre-release bump to 01.06.01-dev [skip ci]
Publish to Composer / Publish Package (release) Successful in 6s
2026-06-29 06:40:51 +00:00
jmiller 50c1b2ba9f refactor: replace Joomla-7-deprecated APIs (forward compatibility)
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 15s
Removes accessors deprecated in Joomla 5 and slated for removal in 7
(extension already runs on 6; this future-proofs for 7):
- Factory::getDbo() -> Factory::getContainer()->get(DatabaseInterface::class)
  across plugins, controllers, static helpers, and the install script
- Factory::getUser() -> Factory::getApplication()->getIdentity()
- Factory::getSession() -> Factory::getApplication()->getSession()
- jexit(Text::_('JINVALID_TOKEN')) -> throw new \RuntimeException(..., 403),
  consistent with the access-denied checks already in those controllers

Note: SQL update-version concern is already resolved — the release bumped
to 01.05.00, which matches the 01.05.00.sql update slot.

(cherry picked from commit d6fb2816cf)
2026-06-29 01:39:51 -05:00
jmiller b66387bacb refactor: rename MokoJoomOpenGraph -> MokoSuiteOpenGraph; require Joomla 6+
Product rename (display name / docs / comments / language strings only —
technical element names mokoog/com_mokoog/MokoOG namespace unchanged):
- Replace "MokoJoom" -> "MokoSuite" across 55 files
- Fixes the update-site license lookup in script.php, which matched the
  old "%MokoJoomOpenGraph%" name and would never find a "MokoSuite" site

Joomla 6 compatibility:
- script.php: minimumJoomla 4.0.0 -> 6.0.0, minimumPhp 8.1.0 -> 8.2.0,
  and actually enforce the Joomla floor in preflight() (was PHP-only)
- Add PKG_MOKOOG_JOOMLA_VERSION_ERROR language strings (en-GB, en-US)
- openapi.yaml + README state Joomla 6.0+ requirement
- Audit confirmed the codebase already uses only Joomla-6-supported APIs

(cherry picked from commit 7fb7e38762)
2026-06-29 01:39:47 -05:00
jmiller 5a696b0e46 docs(api): require Joomla 6.0+ in OpenAPI metadata
(cherry picked from commit 5ea422d75e)
2026-06-29 01:39:13 -05:00
jmiller d9087ac420 fix: harden input handling and output safety (#79)
- canonical_url: sanitize via sanitizeUrl() (scheme allowlist) instead of
  bare trim() — closes stored-XSS via addHeadLink() on the public frontend
- AI endpoint: replace die('Invalid Token') with a clean event result,
  and strip_tags + truncate article_title to 200 chars before use
- SitemapBuilder: whitelist changefreq against the sitemap spec enum,
  intval() noindex IDs, strict in_array comparison
- MokoOG: log a WARNING when sitemap.xml write fails instead of ignoring it

(cherry picked from commit b77054b769)
2026-06-29 01:39:13 -05:00
Jonathan Miller dbf726e148 fix: use mysqli driver in component manifest for Joomla 4/5/6
The install/uninstall/update SQL sections used driver="mysql" which
doesn't match the active mysqli driver, causing "SQL File not found"
on fresh installs and silently skipping schema updates on upgrades.

(cherry picked from commit 3fb5a87be9)
2026-06-29 01:39:12 -05:00
76 changed files with 853 additions and 1576 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
+12 -15
View File
@@ -7,7 +7,7 @@
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.01.00
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +=======================================================================+
@@ -27,7 +27,7 @@ name: "Universal: Build & Release"
on:
pull_request:
types: [opened, synchronize, closed]
types: [opened, closed]
branches:
- main
paths-ignore:
@@ -52,7 +52,7 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
@@ -66,7 +66,6 @@ jobs:
runs-on: release
if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event.action == 'synchronize' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps:
@@ -75,7 +74,6 @@ jobs:
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
submodules: recursive
- name: Setup mokocli tools
env:
@@ -103,7 +101,7 @@ jobs:
php ${MOKO_CLI}/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git
@@ -122,7 +120,7 @@ jobs:
- name: Update RC release notes from CHANGELOG.md
run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Extract [Unreleased] section from changelog
@@ -174,7 +172,6 @@ jobs:
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
submodules: recursive
- name: Configure git for bot pushes
run: |
@@ -271,7 +268,7 @@ jobs:
!startsWith(steps.platform.outputs.platform, 'joomla')
run: |
VERSION="${{ steps.version.outputs.version }}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
SEMVER_TAG="v${VERSION}"
@@ -296,7 +293,7 @@ jobs:
- name: Update release notes and promote changelog
run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Get the stable release info (version and ID)
@@ -365,7 +362,7 @@ jobs:
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
@@ -394,7 +391,7 @@ jobs:
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc)
@@ -418,7 +415,7 @@ jobs:
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}"
@@ -439,7 +436,7 @@ jobs:
if: steps.version.outputs.skip != 'true'
continue-on-error: true
run: |
API_BASE="${MOKOGITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true
@@ -465,5 +462,5 @@ jobs:
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${MOKOGITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
-6
View File
@@ -13,12 +13,6 @@
name: "Generic: Project CI"
on:
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch:
permissions:
+1 -68
View File
@@ -1,68 +1 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/ci-issue-reporter.yml
# VERSION: 01.00.00
# BRIEF: Reusable workflow — creates/updates a Gitea issue when a CI gate fails.
# Clones MokoCLI and runs cli/ci_issue_reporter.sh.
name: "Universal: CI Issue Reporter"
on:
workflow_call:
inputs:
gate:
description: "CI gate name (e.g. PR Validation, Repository Health)"
required: true
type: string
details:
description: "Human-readable failure description"
required: true
type: string
severity:
description: "error or warning"
required: false
type: string
default: "error"
workflow:
description: "Workflow name for the issue title"
required: false
type: string
default: ""
secrets:
MOKOGITEA_TOKEN:
required: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
report:
name: "Report: ${{ inputs.gate }}"
runs-on: ubuntu-latest
steps:
- name: Clone MokoCLI
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 --filter=blob:none --sparse "${MOKOGITEA_URL}/MokoConsulting/MokoCLI.git" /tmp/mokocli
cd /tmp/mokocli && git sparse-checkout set cli/ci_issue_reporter.sh
- name: Report CI failure
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x /tmp/mokocli/cli/ci_issue_reporter.sh
/tmp/mokocli/cli/ci_issue_reporter.sh \
--gate "${{ inputs.gate }}" \
--details "${{ inputs.details }}" \
--severity "${{ inputs.severity }}" \
--workflow "${{ inputs.workflow }}"
IyBDb3B5cmlnaHQgKEMpIDIwMjYgTW9rbyBDb25zdWx0aW5nIDxoZWxsb0Btb2tvY29uc3VsdGluZy50ZWNoPgojCiMgU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IEdQTC0zLjAtb3ItbGF0ZXIKIwojIEZJTEUgSU5GT1JNQVRJT04KIyBERUZHUk9VUDogR2l0ZWEuV29ya2Zsb3cKIyBJTkdST1VQOiBtb2tvY2xpLlVuaXZlcnNhbAojIFJFUE86IGh0dHBzOi8vZ2l0Lm1va29jb25zdWx0aW5nLnRlY2gvTW9rb0NvbnN1bHRpbmcvbW9rb2NsaQojIFBBVEg6IC8ubW9rb2dpdGVhL3dvcmtmbG93cy9jaS1pc3N1ZS1yZXBvcnRlci55bWwKIyBWRVJTSU9OOiAwMS4wMC4wMAojIEJSSUVGOiBSZXVzYWJsZSB3b3JrZmxvdyDigJQgY3JlYXRlcy91cGRhdGVzIGEgR2l0ZWEgaXNzdWUgd2hlbiBhIENJIGdhdGUgZmFpbHMuCiMgICAgICAgIENsb25lcyBNb2tvQ0xJIGFuZCBydW5zIGNsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaC4KCm5hbWU6ICJVbml2ZXJzYWw6IENJIElzc3VlIFJlcG9ydGVyIgoKb246CiAgd29ya2Zsb3dfY2FsbDoKICAgIGlucHV0czoKICAgICAgZ2F0ZToKICAgICAgICBkZXNjcmlwdGlvbjogIkNJIGdhdGUgbmFtZSAoZS5nLiBQUiBWYWxpZGF0aW9uLCBSZXBvc2l0b3J5IEhlYWx0aCkiCiAgICAgICAgcmVxdWlyZWQ6IHRydWUKICAgICAgICB0eXBlOiBzdHJpbmcKICAgICAgZGV0YWlsczoKICAgICAgICBkZXNjcmlwdGlvbjogIkh1bWFuLXJlYWRhYmxlIGZhaWx1cmUgZGVzY3JpcHRpb24iCiAgICAgICAgcmVxdWlyZWQ6IHRydWUKICAgICAgICB0eXBlOiBzdHJpbmcKICAgICAgc2V2ZXJpdHk6CiAgICAgICAgZGVzY3JpcHRpb246ICJlcnJvciBvciB3YXJuaW5nIgogICAgICAgIHJlcXVpcmVkOiBmYWxzZQogICAgICAgIHR5cGU6IHN0cmluZwogICAgICAgIGRlZmF1bHQ6ICJlcnJvciIKICAgICAgd29ya2Zsb3c6CiAgICAgICAgZGVzY3JpcHRpb246ICJXb3JrZmxvdyBuYW1lIGZvciB0aGUgaXNzdWUgdGl0bGUiCiAgICAgICAgcmVxdWlyZWQ6IGZhbHNlCiAgICAgICAgdHlwZTogc3RyaW5nCiAgICAgICAgZGVmYXVsdDogIiIKICAgIHNlY3JldHM6CiAgICAgIE1PS09HSVRFQV9UT0tFTjoKICAgICAgICByZXF1aXJlZDogdHJ1ZQoKZW52OgogIEZPUkNFX0pBVkFTQ1JJUFRfQUNUSU9OU19UT19OT0RFMjQ6IHRydWUKCmpvYnM6CiAgcmVwb3J0OgogICAgbmFtZTogIlJlcG9ydDogJHt7IGlucHV0cy5nYXRlIH19IgogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAoKICAgIHN0ZXBzOgogICAgICAtIG5hbWU6IENsb25lIE1va29DTEkKICAgICAgICBlbnY6CiAgICAgICAgICBNT0tPR0lURUFfVE9LRU46ICR7eyBzZWNyZXRzLk1PS09HSVRFQV9UT0tFTiB9fQogICAgICAgIHJ1bjogfAogICAgICAgICAgTU9LT0dJVEVBX1VSTD0iJHt7IHZhcnMuR0lURUFfVVJMIHx8ICdodHRwczovL2dpdC5tb2tvY29uc3VsdGluZy50ZWNoJyB9fSIKICAgICAgICAgIGdpdCBjbG9uZSAtLWRlcHRoIDEgLS1maWx0ZXI9YmxvYjpub25lIC0tc3BhcnNlICIke01PS09HSVRFQV9VUkx9L01va29Db25zdWx0aW5nL01va29DTEkuZ2l0IiAvdG1wL21va29jbGkKICAgICAgICAgIGNkIC90bXAvbW9rb2NsaSAmJiBnaXQgc3BhcnNlLWNoZWNrb3V0IHNldCBjbGkvY2lfaXNzdWVfcmVwb3J0ZXIuc2gKCiAgICAgIC0gbmFtZTogUmVwb3J0IENJIGZhaWx1cmUKICAgICAgICBlbnY6CiAgICAgICAgICBNT0tPR0lURUFfVE9LRU46ICR7eyBzZWNyZXRzLk1PS09HSVRFQV9UT0tFTiB9fQogICAgICAgICAgTU9LT0dJVEVBX1VSTDogJHt7IHZhcnMuR0lURUFfVVJMIHx8ICdodHRwczovL2dpdC5tb2tvY29uc3VsdGluZy50ZWNoJyB9fQogICAgICAgIHJ1bjogfAogICAgICAgICAgY2htb2QgK3ggL3RtcC9tb2tvY2xpL2NsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaAogICAgICAgICAgL3RtcC9tb2tvY2xpL2NsaS9jaV9pc3N1ZV9yZXBvcnRlci5zaCBcCiAgICAgICAgICAgIC0tZ2F0ZSAiJHt7IGlucHV0cy5nYXRlIH19IiBcCiAgICAgICAgICAgIC0tZGV0YWlscyAiJHt7IGlucHV0cy5kZXRhaWxzIH19IiBcCiAgICAgICAgICAgIC0tc2V2ZXJpdHkgIiR7eyBpbnB1dHMuc2V2ZXJpdHkgfX0iIFwKICAgICAgICAgICAgLS13b3JrZmxvdyAiJHt7IGlucHV0cy53b3JrZmxvdyB9fSIK
+10 -10
View File
@@ -21,7 +21,7 @@ permissions:
contents: write
env:
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
cleanup:
@@ -33,17 +33,17 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
token: ${{ secrets.GA_TOKEN }}
- name: Delete merged branches
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches?limit=50" | jq -r '.[].name')
DELETED=0
@@ -56,7 +56,7 @@ jobs:
# Check if branch is merged into main
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
echo " Deleting merged branch: ${BRANCH}"
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/branches/${BRANCH}" 2>/dev/null || true
DELETED=$((DELETED + 1))
fi
@@ -66,20 +66,20 @@ jobs:
- name: Clean old workflow runs
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
# Get old completed runs
RUNS=$(curl -sS -H "Authorization: token ${MOKOGITEA_TOKEN}" \
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs?status=completed&limit=50" | \
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
DELETED=0
for RUN_ID in $RUNS; do
curl -sS -X DELETE -H "Authorization: token ${MOKOGITEA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
+76
View File
@@ -0,0 +1,76 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
name: "Publish to Composer"
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
release:
types: [published]
workflow_dispatch:
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
publish:
name: Publish Package
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip publish]')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --quiet
- name: Determine version
id: version
run: |
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Package version: ${VERSION}"
# Gitea Composer Registry — auto-publishes from tags
# The tag push itself registers the package at:
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
- name: Verify Gitea registry
run: |
echo "Gitea Composer registry auto-publishes from tags."
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version
- name: Notify Packagist
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
+5 -5
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.07.00
# VERSION: 01.06.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
@@ -19,7 +19,7 @@ permissions:
issues: write
env:
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
@@ -28,8 +28,8 @@ jobs:
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
API="${MOKOGITEA_URL}/api/v1/repos/${{ github.repository }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
@@ -58,7 +58,7 @@ jobs:
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
REPO_URL="${MOKOGITEA_URL}/${{ github.repository }}"
REPO_URL="${GITEA_URL}/${{ github.repository }}"
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
curl -sf -X POST \
+23 -10
View File
@@ -496,26 +496,39 @@ jobs:
steps:
- name: Trigger RC pre-release
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${MOKOGITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with:
gate: "PR Validation"
workflow: "PR Check"
severity: error
details: "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
secrets: inherit
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
+4 -4
View File
@@ -20,7 +20,7 @@ permissions:
contents: read
env:
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
@@ -55,14 +55,14 @@ jobs:
- name: Validate metadata against Joomla manifest
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
php ${MOKO_CLI}/joomla_metadata_validate.php \
--path . \
--token "${MOKOGITEA_TOKEN}" \
--token "${GITEA_TOKEN}" \
--org "${GITEA_ORG}" \
--repo "${GITEA_REPO}" \
--api-base "${MOKOGITEA_URL}/api/v1" \
--api-base "${GITEA_URL}/api/v1" \
--ci
if [ $? -ne 0 ]; then
+1 -6
View File
@@ -7,7 +7,7 @@
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.02.00
# VERSION: 05.01.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
name: "Universal: Pre-Release"
@@ -59,11 +59,6 @@ jobs:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
submodules: recursive
- name: Update submodules to main
run: |
git submodule foreach --quiet 'git checkout main && git pull --quiet origin main' 2>/dev/null || true
- name: Setup mokocli tools
env:
+13 -18
View File
@@ -29,20 +29,12 @@ jobs:
steps:
- name: Rename branch
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
REPO: ${{ github.repository }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
set -euo pipefail
# BRANCH is attacker-controlled (PR head ref). Strict allowlist before ANY use.
if ! printf '%s' "$BRANCH" | grep -Eq '^rc/[A-Za-z0-9._/-]+$'; then
echo "::error::Refusing unsafe branch name: $BRANCH"; exit 1
fi
BRANCH="${{ github.event.pull_request.head.ref }}"
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${GITEA_URL}/api/v1/repos/${REPO}/branches"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
@@ -50,22 +42,25 @@ jobs:
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"; exit 1
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi
# Read BRANCH from the environment inside PHP (getenv, no string interpolation -> no PHP injection)
ENCODED=$(php -r 'echo rawurlencode(getenv("BRANCH"));')
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> "$GITHUB_STEP_SUMMARY"
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> "$GITHUB_STEP_SUMMARY"
echo "${BRANCH} → ${DEV_BRANCH}" >> "$GITHUB_STEP_SUMMARY"
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
+37 -25
View File
@@ -77,7 +77,7 @@ jobs:
- name: Check actor permission (admin only)
id: perm
env:
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
@@ -671,30 +671,42 @@ jobs:
# ═══════════════════════════════════════════════════════════════════════
# Issue Reporter — file issues for failed gates
# ═══════════════════════════════════════════════════════════════════════
report-scripts:
name: "Report: Scripts Governance"
needs: [access_check, scripts_governance]
report-issues:
name: "Report Issues"
runs-on: ubuntu-latest
needs: [access_check, scripts_governance, repo_health]
if: >-
always() &&
needs.scripts_governance.result == 'failure'
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with:
gate: "Scripts Governance"
workflow: "Repo Health"
severity: error
details: "Scripts directory policy violations detected. Review required and allowed directories."
secrets: inherit
(needs.scripts_governance.result == 'failure' ||
needs.repo_health.result == 'failure')
report-health:
name: "Report: Repository Health"
needs: [access_check, repo_health]
if: >-
always() &&
needs.repo_health.result == 'failure'
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
with:
gate: "Repository Health"
workflow: "Repo Health"
severity: error
details: "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
secrets: inherit
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issues for failed gates"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
REPORTER="./automation/ci-issue-reporter.sh"
WF="Repo Health"
report_gate() {
local gate="$1" result="$2" details="$3"
if [ "$result" = "failure" ]; then
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
fi
}
report_gate "Scripts Governance" \
"${{ needs.scripts_governance.result }}" \
"Scripts directory policy violations detected. Review required and allowed directories."
report_gate "Repository Health" \
"${{ needs.repo_health.result }}" \
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
+312
View File
@@ -0,0 +1,312 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/update-server.yml
# VERSION: 05.00.00
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
#
# Thin wrapper around moko-platform CLI tools.
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
#
# Joomla filters update entries by the user's "Minimum Stability" setting.
name: "Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update Server
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || true
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
- name: Detect platform
id: platform
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve stability and bump version
id: meta
run: |
BRANCH="${{ github.ref_name }}"
# Configure git for bot pushes
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Auto-bump patch version
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Strip any existing suffix before applying stability
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
# Determine stability from branch or manual input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
else
STABILITY="development"
fi
# Version suffix per stability stream
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
beta) SUFFIX="-beta"; TAG="beta" ;;
rc) SUFFIX="-rc"; TAG="release-candidate" ;;
*) SUFFIX=""; TAG="stable" ;;
esac
# Propagate version with stability suffix to all manifest files
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Re-read version (now includes suffix from version_set_platform)
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "display_version=${VERSION}" >> "$GITHUB_OUTPUT"
# Commit version bump if changed
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): auto-bump ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
- name: Create release and upload package
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Create or update Gitea release
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
# Build package and upload
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml — skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push updates.xml
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push
}
- name: Sync updates.xml to main
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
python3 -c "
import base64, json, urllib.request, sys
with open('updates.xml', 'rb') as f:
content = base64.b64encode(f.read()).decode()
payload = json.dumps({
'content': content,
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
'branch': 'main'
}).encode()
req = urllib.request.Request(
'${API_BASE}/contents/updates.xml',
data=payload, method='PUT',
headers={
'Authorization': 'token ${GITEA_TOKEN}',
'Content-Type': 'application/json'
})
try:
urllib.request.urlopen(req)
print('updates.xml synced to main')
except Exception as e:
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
"
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# Permission check: admin or maintain role required
ACTOR="${{ github.actor }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
DISPLAY="${{ steps.meta.outputs.display_version }}"
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY}\` |" >> $GITHUB_STEP_SUMMARY
File diff suppressed because one or more lines are too long
+4 -12
View File
@@ -13,7 +13,6 @@
name: "Universal: Workflow Sync Trigger"
on:
workflow_dispatch:
pull_request:
types: [closed]
branches:
@@ -27,9 +26,8 @@ jobs:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]'))
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
steps:
- name: Determine platform from repo name
@@ -51,14 +49,8 @@ jobs:
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
MOKOGITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${MOKOGITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install PHP
run: |
if ! command -v php &> /dev/null; then
apt-get update -qq && apt-get install -y -qq php-cli php-json php-curl > /dev/null 2>&1
fi
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install dependencies
run: |
+2 -36
View File
@@ -1,46 +1,12 @@
# Changelog
## [Unreleased]
<!-- VERSION: 01.06.01 -->
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 -->
## [01.07.00] --- 2026-06-29
### 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)
- 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)
- Component **Options** screen with a Permissions tab, plus `access.xml` ACL actions `mokoog.batch` and `mokoog.import` (#95)
- `og_video`, `event_data`, `recipe_data`, and `custom_schema` are now included in CSV import/export and the REST API (#101)
- Unit tests for `JsonLdBuilder::buildLocalBusiness()` and `toScriptTag()` (#33)
### Changed
- **Require Joomla 6.0+ and PHP 8.2+** (enforced at install)
- Renamed the product from *MokoJoomOpenGraph* to **MokoSuiteOpenGraph**
- Forward-compatibility for Joomla 7: replaced deprecated `Factory::getDbo/getUser/getSession/getLanguage`, `Joomla\CMS\Filesystem\File/Folder`, and `jexit()` (#102)
- Aligned OG/SEO form `maxlength` values with the database column limits (#77)
- Moved coverage metrics out of the tag list into a dedicated model (no longer runs uncached `COUNT` queries on every list load)
### Fixed
- Fatal frontend error (HTTP 500) when a non-object value was saved into the custom JSON-LD field — values are now validated as objects/arrays on save and guarded on render (#97)
- Stored XSS via the canonical URL field — now restricted to `http`/`https` (#79)
- Use the `mysqli` driver in the component manifest so install/upgrade SQL actually runs on Joomla 4/5/6
- `loadArticle()` now caches negative lookups; zero dates are no longer emitted as `article:published_time`/`article:modified_time` (#106)
### Security
- AI meta-generation endpoint now requires article-edit permission and enforces an HTTP timeout and status check — previously any authenticated back-end user could trigger paid API calls (#99)
- XML sitemap now excludes content above the public view level (no longer leaks registered/special-access articles) and writes atomically (#100)
### Removed
- Unused `ImageGenerator` class and `JsonLdBuilder::buildOrganization()`; generated OG images are now pruned after 30 days to bound disk usage (#104)
- Empty `src/Field` and `src/Service` stub directories; packaged the `en-US` language folder (#107)
## [01.05.00] --- 2026-06-28
## [Unreleased]
### Security
- Fix JSON-LD XSS vulnerability via `</script>` injection in content data (#34)
+18 -36
View File
@@ -1,46 +1,28 @@
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
This file is part of a Moko Consulting project.
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License (./LICENSE).
# FILE INFORMATION
DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation
REPO: https://github.com/mokoconsulting-tech/Template-Joomla/
VERSION: 01.07.00
PATH: ./CODE_OF_CONDUCT.md
BRIEF: Community expectations and enforcement guidelines
NOTE: Adapted with attribution from the Contributor Covenant v2.1
-->
# Contributor Covenant Code of Conduct
# Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone.
We pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
- Be empathetic and kind
- Be respectful of differing opinions
- Accept constructive feedback
- Own mistakes and learn from them
Unacceptable behavior includes sexualized language/imagery, trolling, harassment, doxing, and other inappropriate conduct.
Examples of behavior that contributes to a positive environment:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
Examples of unacceptable behavior:
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
## Enforcement
Report incidents to **hello@mokoconsulting.tech** or through GitHub Discussions if you prefer a community-visible approach. Private complaints will be reviewed promptly and fairly.
## Enforcement Guidelines
1. **Correction** — Private warning
2. **Warning** — Formal warning and limited interaction
3. **Temporary Ban** — Time-boxed exclusion
4. **Permanent Ban** — Removal from the community
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at hello@mokoconsulting.tech. All complaints will be reviewed and investigated.
## Attribution
Adapted from the Contributor Covenant v2.1.
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
+22 -149
View File
@@ -1,161 +1,34 @@
# Contributing to Moko Consulting Projects
# Contributing to MokoJoomOpenGraph
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
Thank you for your interest in contributing to MokoJoomOpenGraph.
## Branching Workflow
## Getting Started
```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
```
1. Fork the repository on Gitea
2. Create a feature branch from `dev` (`feature/your-feature`)
3. Make your changes following the coding standards below
4. Submit a pull request targeting `dev`
### Step by step
## Branch Strategy
1. **Create a feature branch** from `dev`:
```bash
git checkout dev && git pull
git checkout -b feature/my-change
```
- `main` — stable releases only
- `dev` — active development
- `feature/*` — new features (target `dev`)
- `fix/*` — bug fixes (target `dev`)
- `hotfix/*` — urgent fixes (target `dev` or `main`)
2. **Work and commit** on your feature branch. Push to origin.
## Coding Standards
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
- This automatically renames the source branch to `rc` (release candidate)
- An RC pre-release is built and uploaded
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
- When the draft PR is created, the branch is renamed to `rc`
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
7. **Merging to main** triggers the stable release pipeline:
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
- Stability suffix stripped (clean version)
- Gitea release created with ZIP/tar.gz packages
- `updates.xml` updated (Joomla extensions)
- `dev` branch recreated from `main`
### Branch summary
| Branch | Purpose | Created by |
|--------|---------|-----------|
| `feature/*` | New features and fixes | Developer |
| `dev` | Integration branch | Auto-recreated after release |
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
| `rc` | Release candidate | Auto-renamed on draft PR to main |
| `main` | Stable releases | Protected, merge only |
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
### Protected branches
| Branch | Direct push | Merge via |
|--------|------------|-----------|
| `main` | Blocked (CI bot whitelisted) | PR merge only |
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
| `feature/*` | Open | N/A (source branch) |
## Version Policy
### Format
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
- **XX** — Major version (breaking changes)
- **YY** — Minor version (new features, bumped on release to main)
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
### Stability suffixes
Each branch appends a suffix to indicate stability:
| Branch | Suffix | Example |
|--------|--------|---------|
| `main` | (none) | `02.09.00` |
| `dev` | `-dev` | `02.09.01-dev` |
| `feature/*` | `-dev` | `02.09.01-dev` |
| `alpha` | `-alpha` | `02.09.01-alpha` |
| `beta` | `-beta` | `02.09.01-beta` |
| `rc` | `-rc` | `02.09.01-rc` |
### Auto version bump
On every push to `dev`, `feature/*`, or `patch/*`:
1. Patch version incremented
2. Stability suffix `-dev` applied
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
4. Commit created with `[skip ci]` to avoid loops
### Release version flow
Version bumps happen at specific release events:
| Event | Bump | Example |
|-------|------|---------|
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
### Release stream copies
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
### Version files
The version tools update all files containing version stamps:
- `.mokogitea/manifest.xml` (canonical source)
- Joomla XML manifests (`<version>` tag)
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
- `package.json`, `pyproject.toml`
- Any text file with a `VERSION: XX.YY.ZZ` label
Files synced from other repos (with a `# REPO:` header) are not touched.
## Code Standards
- **PHP**: PSR-12, tabs for indentation
- **Copyright**: all files must include the Moko Consulting copyright header
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
## Commit Messages
Use conventional commit format:
```
type(scope): short description
Optional body with context.
Authored-by: Moko Consulting
```
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
Special flags in commit messages:
- `[skip ci]` — skip all CI workflows
- `[skip bump]` — skip auto version bump only
- PHP 8.1+ required
- Follow Joomla coding standards
- SPDX license headers on all PHP files
- Use `SubscriberInterface` for event subscription
- Use `bind() -> check() -> store()` for Table operations
## Reporting Issues
Use the repository's issue tracker with the appropriate template.
Report bugs and feature requests via [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/issues).
---
## License
*Moko Consulting <hello@mokoconsulting.tech>*
By contributing, you agree that your contributions will be licensed under GPL-3.0-or-later.
+1 -119
View File
File diff suppressed because one or more lines are too long
+7 -10
View File
@@ -1,6 +1,6 @@
# MokoSuiteOpenGraph
<!-- VERSION: 01.07.00 -->
<!-- VERSION: 01.06.01 -->
Open Graph, Twitter Card, and social sharing meta tag management for Joomla 6 and higher.
@@ -45,24 +45,21 @@ MokoSuiteOpenGraph gives you full control over how your Joomla content appears w
- **Debug links** — Quick links to Facebook Debugger, LinkedIn Inspector, Google Rich Results
- **Live preview** — Real-time Facebook, Twitter/X, LinkedIn, Discord, Mastodon, and Slack card previews in the editor
- **Character count indicators** — Green/yellow/red warnings on OG and SEO text fields
- **Coverage dashboard** — Default admin view: coverage donut, breakdown by content type, and a list of articles missing OG tags with quick batch-generate
- **Manual tag editor** — Create and edit individual OG tag records directly in the admin
- **Component permissions** — ACL actions (`mokoog.batch`, `mokoog.import`) configurable from the component Options → Permissions
- **AI meta generation** — Generate OG titles and descriptions with Claude or OpenAI (article-edit permission required)
- **OG coverage dashboard** — Coverage percentage and missing field counts
- **AI meta generation** — Generate OG titles and descriptions with Claude or OpenAI
### Developer Features
- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`)
- **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta
- **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags
- **Per-platform image resizing** — Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400, with auto-resize to 1200x630
- **XML sitemap** — Auto-generates sitemap.xml on article save; respects noindex and public access levels, written atomically
- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630
- **Per-platform image resizing** — Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400
- **XML sitemap** — Auto-generates sitemap.xml on article save, respects noindex
- **OpenAPI spec** — Full REST API documentation at `openapi.yaml`
- **PHPUnit tests** — Unit tests for JsonLdBuilder schema outputs and JSON-LD script-tag escaping
- **PHPUnit tests** — 16 unit tests for JsonLdBuilder schema outputs
## Installation
**Requirements:** Joomla 6.0 or higher and PHP 8.2 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
3. All plugins are enabled automatically on install
+4 -4
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.06.01
BRIEF: Security vulnerability reporting and handling policy
-->
@@ -224,10 +224,10 @@ The following are explicitly out of scope:
## Metadata
| Field | Value |
| Field | Value |
| ------------ | ------------------------------------------------------------------------------------------------------------ |
| Document | Security Policy |
| Path | /SECURITY.md |
| Document | Security Policy |
| Path | /SECURITY.md |
| Repository | [https://github.com/mokoconsulting-tech/Template-Joomla](https://github.com/mokoconsulting-tech/Template-Joomla) |
| Owner | Moko Consulting |
| Scope | Security vulnerability handling |
-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_image',
'og_type',
'og_video',
'seo_title',
'meta_description',
'robots',
'canonical_url',
'event_data',
'recipe_data',
'custom_schema',
'language',
'published',
'created',
@@ -58,14 +54,10 @@ class JsonapiView extends BaseApiView
'og_description',
'og_image',
'og_type',
'og_video',
'seo_title',
'meta_description',
'robots',
'canonical_url',
'event_data',
'recipe_data',
'custom_schema',
'language',
'published',
'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"
type="text"
label="COM_MOKOOG_FIELD_CONTENT_TYPE"
description="COM_MOKOOG_FIELD_CONTENT_TYPE_DESC"
required="true"
readonly="true"
/>
<field
name="content_id"
type="number"
label="COM_MOKOOG_FIELD_CONTENT_ID"
description="COM_MOKOOG_FIELD_CONTENT_ID_DESC"
required="true"
readonly="true"
/>
<field
name="og_title"
@@ -79,45 +77,37 @@
<option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</option>
</field>
<field
name="language"
type="contentlanguage"
label="JFIELD_LANGUAGE_LABEL"
default="*"
>
<option value="*">JALL</option>
</field>
</fieldset>
<fieldset name="seo" label="COM_MOKOOG_FIELDSET_SEO">
<fieldset name="seo" label="SEO Meta Tags">
<field
name="seo_title"
type="text"
label="COM_MOKOOG_FIELD_SEO_TITLE"
description="COM_MOKOOG_FIELD_SEO_TITLE_DESC"
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
filter="string"
maxlength="70"
maxlength="255"
/>
<field
name="meta_description"
type="textarea"
label="COM_MOKOOG_FIELD_META_DESCRIPTION"
description="COM_MOKOOG_FIELD_META_DESCRIPTION_DESC"
label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
filter="string"
rows="3"
maxlength="200"
maxlength="255"
/>
<field
name="robots"
type="text"
label="COM_MOKOOG_FIELD_ROBOTS"
description="COM_MOKOOG_FIELD_ROBOTS_DESC"
label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
filter="string"
/>
<field
name="canonical_url"
type="url"
label="COM_MOKOOG_FIELD_CANONICAL_URL"
description="COM_MOKOOG_FIELD_CANONICAL_URL_DESC"
label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
filter="url"
/>
</fieldset>
@@ -5,13 +5,6 @@
COM_MOKOOG="MokoSuiteOpenGraph"
COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
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_TABLE_CAPTION="Table of Open Graph tags"
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_DESC="%d tags missing custom description"
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_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager"
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_TABLE_CAPTION="Table of Open Graph tags"
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_DESC="%d tags missing custom description"
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">
<name>com_mokoog</name>
<version>01.07.00</version>
<version>01.06.01</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -50,8 +50,6 @@
<folder>View</folder>
</files>
<files folder="tmpl">
<folder>dashboard</folder>
<folder>tag</folder>
<folder>tags</folder>
</files>
<files folder="sql">
@@ -65,15 +63,9 @@
</files>
<files folder="language">
<folder>en-GB</folder>
<folder>en-US</folder>
</files>
<files>
<filename>access.xml</filename>
<filename>config.xml</filename>
</files>
<menu img="class:bookmark">COM_MOKOOG</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>
</submenu>
</administration>
@@ -1 +0,0 @@
/* 01.04.12 — no schema changes */
@@ -1 +0,0 @@
/* 01.04.13 — no schema changes */
@@ -1 +0,0 @@
/* 01.04.14 — no schema changes */
@@ -1 +0,0 @@
/* 01.04.15 — no schema changes */
@@ -1 +0,0 @@
/* 01.04.16 — no schema changes */
@@ -1 +0,0 @@
/* 01.04.17 — no schema changes */
@@ -1 +0,0 @@
/* 01.04.18 — no schema changes */
@@ -1 +0,0 @@
/* 01.05.01 — no schema changes */
@@ -1 +0,0 @@
/* 01.05.02 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.00 — no schema changes */
@@ -0,0 +1 @@
/* 01.06.01 — 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 */
@@ -1 +0,0 @@
/* 01.06.10 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.11 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.12 — no schema changes */
@@ -1 +0,0 @@
/* 01.06.13 — no schema changes */
@@ -1 +0,0 @@
/* 01.07.00 — no schema changes */
@@ -29,10 +29,7 @@ class BatchController extends BaseController
{
Session::checkToken('get') || throw new \RuntimeException(Text::_('JINVALID_TOKEN'), 403);
$identity = Factory::getApplication()->getIdentity();
if (!$identity->authorise('mokoog.batch', 'com_mokoog')
&& !$identity->authorise('core.create', 'com_mokoog')) {
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
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);
$identity = Factory::getApplication()->getIdentity();
if (!$identity->authorise('mokoog.batch', 'com_mokoog')
&& !$identity->authorise('core.create', 'com_mokoog')) {
if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
}
@@ -21,5 +21,5 @@ class DisplayController extends BaseController
*
* @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.canonical_url'),
$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'))
->leftJoin(
@@ -88,7 +84,7 @@ class ImportExportController extends BaseController
'content_type', 'content_id', 'article_title',
'og_title', 'og_description', 'og_image', 'og_type',
'seo_title', 'meta_description', 'robots', 'canonical_url',
'language', 'og_video', 'event_data', 'recipe_data', 'custom_schema',
'language',
]);
foreach ($rows as $row) {
@@ -110,8 +106,7 @@ class ImportExportController extends BaseController
$identity = Factory::getApplication()->getIdentity();
if (!$identity->authorise('mokoog.import', 'com_mokoog')
&& !($identity->authorise('core.create', 'com_mokoog') && $identity->authorise('core.edit', 'com_mokoog'))) {
if (!$identity->authorise('core.create', 'com_mokoog') || !$identity->authorise('core.edit', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
}
@@ -192,10 +187,6 @@ class ImportExportController extends BaseController
$robots = trim($row[9] ?? '');
$canonicalUrl = trim($row[10] ?? '');
$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', '*')
if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) {
@@ -238,10 +229,6 @@ class ImportExportController extends BaseController
'robots' => $robots,
'canonical_url' => $canonicalUrl,
'language' => $language,
'og_video' => $ogVideo,
'event_data' => $eventData,
'recipe_data' => $recipeData,
'custom_schema' => $customSchema,
'published' => 1,
'modified' => $now,
];
@@ -265,45 +252,4 @@ class ImportExportController extends BaseController
);
$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
{
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('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::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();
?>
<?php include __DIR__ . '/coverage.php'; ?>
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm">
<div class="row">
<div class="col-md-12">
@@ -84,9 +85,7 @@ $token = Session::getFormToken();
<?php echo (int) $item->content_id; ?>
</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') . ')'); ?>
</a>
<?php echo $this->escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?>
</td>
<td>
<?php if ($item->og_image) : ?>
@@ -172,23 +171,6 @@ $token = Session::getFormToken();
</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>
document.addEventListener('DOMContentLoaded', function() {
// Intercept the batch.generate toolbar button
@@ -198,13 +180,6 @@ document.addEventListener('DOMContentLoaded', function() {
mokoogBatchGenerate();
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) {
origSubmitbutton(task);
}
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteOpenGraph</name>
<version>01.07.00</version>
<version>01.06.01</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -322,14 +322,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
{
$json = trim($json);
if ($json === '') {
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))) {
if ($json === '' || json_decode($json) === null) {
return '';
}
+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.06.01</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -139,7 +139,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
}
// og:locale from current language
$langTag = $this->getApplication()->getLanguage()->getTag();
$langTag = Factory::getLanguage()->getTag();
$ogLocale = str_replace('-', '_', $langTag);
$doc->setMetaData('og:locale', $ogLocale, 'property');
@@ -358,9 +358,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
if (!empty($customSchema)) {
$decoded = json_decode($customSchema, true);
// Guard against scalar/invalid payloads — only arrays/objects are
// valid JSON-LD. Writing an array offset onto a scalar is fatal.
if (\is_array($decoded) && $decoded !== []) {
if ($decoded) {
if (empty($decoded['@context'])) {
$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_id') . ' = ' . (int) $id)
->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('*') . ')')
->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
{
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$lang = $this->getApplication()->getLanguage()->getTag();
$lang = Factory::getLanguage()->getTag();
$query = $db->getQuery(true)
->select('*')
@@ -523,7 +521,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
private function loadOgDataByMenu(int $menuId): ?object
{
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
$lang = $this->getApplication()->getLanguage()->getTag();
$lang = Factory::getLanguage()->getTag();
$query = $db->getQuery(true)
->select('*')
@@ -672,9 +670,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
{
static $cache = [];
// array_key_exists (not isset) so a negative lookup (null) is also cached
// and not re-queried on every call within the request.
if (\array_key_exists($id, $cache)) {
if (isset($cache[$id])) {
return $cache[$id];
}
@@ -706,15 +702,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
private function getArticleDate(int $id, string $field): string
{
$article = $this->loadArticle($id);
$value = $article->$field ?? '';
// Skip zero/empty dates — emitting "0000-00-00 00:00:00" as
// article:published_time/modified_time produces invalid metadata.
if ($value === '' || str_starts_with($value, '0000-00-00')) {
return '';
}
return $value;
return $article->$field ?? '';
}
/**
@@ -831,10 +820,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
*/
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)) {
return;
}
@@ -873,14 +858,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
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)) {
$event->setArgument('result', ['AI generation is not enabled']);
return;
@@ -925,9 +902,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
{
$http = \Joomla\CMS\Http\HttpFactory::getHttp();
// Cap how long a hung provider can block the admin request.
$timeout = 20;
if ($provider === 'claude') {
$response = $http->post(
'https://api.anthropic.com/v1/messages',
@@ -940,14 +914,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
'Content-Type' => 'application/json',
'x-api-key' => $apiKey,
'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);
return trim($data['content'][0]['text'] ?? '');
@@ -963,14 +932,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
[
'Content-Type' => 'application/json',
'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);
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;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
use Joomla\CMS\Filesystem\File;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log;
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.
*
@@ -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.
*
@@ -37,20 +37,12 @@ class SitemapBuilder
$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
$query = $db->getQuery(true)
->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language']))
->from($db->quoteName('#__content', 'a'))
->where($db->quoteName('a.state') . ' = 1');
if (!empty($publicLevels)) {
$query->where($db->quoteName('a.access') . ' IN (' . implode(',', $publicLevels) . ')');
}
$db->setQuery($query);
$articles = $db->loadObjectList();
@@ -112,19 +104,7 @@ class SitemapBuilder
public static function writeToFile(string $xml): bool
{
$path = JPATH_ROOT . '/sitemap.xml';
$tmp = $path . '.' . uniqid('tmp', true);
if (file_put_contents($tmp, $xml) === false) {
return false;
}
// Atomic replace so concurrent saves never expose a half-written sitemap.
if (!@rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
return (bool) file_put_contents($path, $xml);
}
}
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteOpenGraph</name>
<version>01.07.00</version>
<version>01.06.01</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+2 -2
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.06.01</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -31,7 +31,7 @@
</languages>
<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>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>