Compare commits

..

53 Commits

Author SHA1 Message Date
jmiller 537f4539c8 chore: add dlid and blockChildUninstall to package manifest [skip ci] 2026-06-04 22:02:37 +00:00
jmiller 036f8b9877 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:58:45 +00:00
jmiller ba22067c56 chore: standardize updateservers URL [skip ci] 2026-06-04 15:48:27 +00:00
jmiller 2c0cbc5a13 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:41:27 +00:00
jmiller 6beea230a8 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:32:43 +00:00
jmiller cd76449f79 chore: remove updates.xml [skip ci] 2026-06-04 15:27:10 +00:00
jmiller cd0590cee4 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:19:32 +00:00
jmiller 05914c0c70 feat(update): migrate update server URL to Gitea Pages [skip ci] 2026-06-04 14:33:58 +00:00
jmiller 6b752babd3 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-04 14:23:54 +00:00
jmiller 466eb7da3c chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-04 13:47:36 +00:00
Moko Consulting 2dbb285fdf chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 20:38:30 +00:00
Moko Consulting 52d67f5fb1 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 20:38:29 +00:00
Moko Consulting 42ca6325c7 chore(ci): add CI issue reporter for auto-filing gate failures
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-02 20:38:29 +00:00
jmiller ee060243f5 chore: sync .mokogitea/workflows/cascade-dev.yml from moko-platform [skip ci] 2026-05-31 01:42:43 +00:00
jmiller 9bd14d3547 chore: sync updates.xml from development [skip ci] 2026-05-31 01:40:51 +00:00
jmiller b75e7ccf10 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-31 01:10:44 +00:00
jmiller 8fd232c959 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-05-30 16:02:10 +00:00
jmiller fde6df7398 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-30 15:00:19 +00:00
jmiller ec8545c7d3 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 14:56:47 +00:00
jmiller 63e87a0c4d chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-30 14:54:49 +00:00
jmiller 12143fc4b1 chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-30 05:51:55 +00:00
jmiller abb9238ebe chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 03:41:46 +00:00
jmiller d162f2317c chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 01:15:29 +00:00
jmiller c02bb54759 chore: add .mokogitea/branch-protection.yml from moko-platform [skip ci] 2026-05-29 10:30:43 +00:00
jmiller 4b682c5ebd chore: add CONTRIBUTING.md from moko-platform [skip ci] 2026-05-29 10:28:08 +00:00
jmiller e0b4008ac7 chore: add .mokogitea/workflows/branch-cleanup.yml from moko-platform [skip ci] 2026-05-29 10:26:31 +00:00
jmiller bd220a7d7c chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-29 10:25:03 +00:00
jmiller 9d79830b02 chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-29 10:23:34 +00:00
jmiller 8610aa5fcd chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-28 20:54:19 +00:00
jmiller 821a3398a5 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-28 20:49:12 +00:00
jmiller 354081d7a5 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:44:29 +00:00
jmiller 1ad79be9b2 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:38:34 +00:00
gitea-actions[bot] 8fb6a84e81 feat(ci): add version branch creation on stable release [skip ci] 2026-05-27 02:19:25 +00:00
jmiller 7da249ea73 chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:51:25 +00:00
jmiller 9d628412e0 chore(ci): update auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:50:14 +00:00
jmiller 516f7b4832 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:49:02 +00:00
jmiller d35660b4cf chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:37:36 +00:00
jmiller be05c56d29 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:36:08 +00:00
jmiller 608ad43242 chore(ci): update auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:25:47 +00:00
jmiller f92b53d24e chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:24:31 +00:00
jmiller 6d6840c68b chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:13:53 +00:00
jmiller 485b0cf696 chore(ci): add auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:12:40 +00:00
jmiller a8805d16f1 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-26 20:13:20 +00:00
jmiller e4dad451b4 chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-26 20:11:27 +00:00
jmiller 42d9de47d5 fix(ci): use release_package.php for Joomla package builds [skip ci] 2026-05-26 19:54:39 +00:00
jmiller 9753088798 chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 19:36:23 +00:00
jmiller b01107d6e6 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 19:36:22 +00:00
jmiller fd7ccfb927 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-26 19:04:39 +00:00
gitea-actions[bot] 2fd22c6030 refactor(ci): clean up auto-release, move logic to CLI [skip ci] 2026-05-25 22:21:03 -05:00
jmiller 8054c6c80b chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-26 03:08:06 +00:00
jmiller 7c90f05e9d chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-26 03:06:09 +00:00
gitea-actions[bot] 648549ea66 fix(ci): auto-release preserves all update channels [skip ci] 2026-05-25 21:59:27 -05:00
jmiller 52b2d89bc5 feat(ci): add issue-branch.yml [skip ci] 2026-05-25 05:13:01 +00:00
58 changed files with 2068 additions and 3828 deletions
+251
View File
@@ -0,0 +1,251 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/branch-protection.yml
# BRIEF: Apply standardised branch protection rules to all governed repositories
#
# +========================================================================+
# | BRANCH PROTECTION SETUP |
# +========================================================================+
# | |
# | Applies protection rules for: main, dev, rc, beta, alpha |
# | |
# | main — Require PR, block rejected reviews, no force push |
# | dev — Allow push, no force push, no delete |
# | rc — Allow push, no force push, no delete |
# | beta — Allow push, no force push, no delete |
# | alpha — Allow push, no force push, no delete |
# | |
# | jmiller has override authority on all branches. |
# | |
# +========================================================================+
name: Branch Protection Setup
on:
schedule:
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
workflow_dispatch:
inputs:
dry_run:
description: 'Preview mode (no changes)'
required: false
type: boolean
default: false
repos:
description: 'Comma-separated repo names (empty = all governed repos)'
required: false
type: string
default: ''
env:
GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting
permissions:
contents: read
jobs:
protect:
name: Apply Branch Protection Rules
runs-on: ubuntu-latest
steps:
- name: Determine target repos
id: repos
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1"
# Platform/standards/infra repos to exclude
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then
# User-specified repos
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
else
# Fetch all org repos
PAGE=1
REPOS=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
REPOS="$REPOS $BATCH"
PAGE=$((PAGE + 1))
done
# Filter out excluded repos
FILTERED=""
for REPO in $REPOS; do
SKIP=false
for EX in $EXCLUDE; do
if [ "$REPO" = "$EX" ]; then
SKIP=true
break
fi
done
if [ "$SKIP" = "false" ]; then
FILTERED="$FILTERED $REPO"
fi
done
REPOS="$FILTERED"
fi
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$REPOS" | wc -w)
echo "📋 Target repos (${COUNT}): $REPOS"
- name: Apply protection rules
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: |
API="${GITEA_URL}/api/v1"
REPOS="${{ steps.repos.outputs.repos }}"
SUCCESS=0
FAILED=0
SKIPPED=0
# ── Rule definitions ──────────────────────────────────────
# Only the CI bot (jmiller token) can push directly.
# All human contributors must use PRs.
# Force push disabled on all branches.
RULE_MAIN='{
"rule_name": "main",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"dismiss_stale_approvals": true,
"block_on_rejected_reviews": true,
"block_on_outdated_branch": false,
"priority": 1
}'
RULE_DEV='{
"rule_name": "dev",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 2
}'
RULE_RC='{
"rule_name": "rc",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 3
}'
RULE_BETA='{
"rule_name": "beta",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 4
}'
RULE_ALPHA='{
"rule_name": "alpha",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 5
}'
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
# ── Apply rules to each repo ──────────────────────────────
for REPO in $REPOS; do
echo ""
echo "═══ ${REPO} ═══"
for i in "${!RULES[@]}"; do
RULE="${RULES[$i]}"
NAME="${RULE_NAMES[$i]}"
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would apply rule: ${NAME}"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Delete existing rule if present (idempotent recreate)
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
curl -sS -o /dev/null -w "" \
-X DELETE \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
# Create rule
RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$RULE" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
HTTP=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP" = "201" ]; then
echo " ✅ ${NAME}"
SUCCESS=$((SUCCESS + 1))
else
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
FAILED=$((FAILED + 1))
fi
done
done
# ── Summary ───────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo " ✅ Success: ${SUCCESS}"
echo " ❌ Failed: ${FAILED}"
echo " ⏭️ Skipped: ${SKIPPED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
echo "::warning::${FAILED} rule(s) failed to apply"
fi
+18 -3
View File
@@ -102,13 +102,14 @@ jobs:
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC + lesser stream releases built, updates.xml synced" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release:
@@ -131,6 +132,19 @@ jobs:
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"
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found — aborting release"
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
@@ -154,7 +168,8 @@ jobs:
run: |
php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
+48
View File
@@ -0,0 +1,48 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
cleanup:
name: Delete merged branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'dev' &&
github.event.pull_request.head.ref != 'main'
steps:
- name: Delete source branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
elif [ "$STATUS" = "404" ]; then
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
fi
+7 -210
View File
@@ -1,213 +1,10 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/cascade-dev.yml.template
# VERSION: 02.00.00
# BRIEF: Forward-merge main → all open branches after every push to main
#
# +========================================================================+
# | CASCADE MAIN → ALL BRANCHES |
# +========================================================================+
# | |
# | Triggers on every push to main (PR merges, bot commits, etc.) |
# | |
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
# | 2. For each: create PR (main → branch), auto-merge if clean |
# | 3. On conflict: leave PR open for manual resolution |
# | |
# +========================================================================+
name: "Universal: Cascade Main → Dev"
on:
push:
branches:
- main
workflow_dispatch:
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
pull-requests: write
# DISABLED — auto-release Step 11 recreates dev from main after every release.
# Cascade-dev is redundant and causes version conflicts when both main and dev
# have different version numbers in templateDetails.xml / manifest.xml.
name: "Cascade Main → Dev (DISABLED)"
on: workflow_dispatch
jobs:
cascade:
name: Cascade main → branches
noop:
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip cascade]')
steps:
- name: Discover target branches
id: branches
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Fetch all branches (paginated)
PAGE=1
ALL_BRANCHES=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/branches?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
PAGE=$((PAGE + 1))
done
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
TARGETS=""
for BRANCH in $ALL_BRANCHES; do
case "$BRANCH" in
dev|dev/*|rc/*|beta/*|alpha/*)
TARGETS="$TARGETS $BRANCH"
;;
esac
done
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
if [ -z "$TARGETS" ]; then
echo "targets=" >> "$GITHUB_OUTPUT"
echo "️ No cascade target branches found"
else
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$TARGETS" | wc -w)
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
fi
- name: Cascade to all target branches
if: steps.branches.outputs.targets != ''
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
SHORT_SHA="${GITHUB_SHA:0:7}"
TARGETS="${{ steps.branches.outputs.targets }}"
SUCCESS=0
CONFLICTS=0
SKIPPED=0
FAILED=0
for BRANCH in $TARGETS; do
echo ""
echo "═══ main → ${BRANCH} ═══"
# Check if branch is already up to date
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
RESPONSE=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/compare/${ENCODED_BRANCH}...main")
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
if [ "$AHEAD" -eq 0 ]; then
echo " ✅ Already up to date"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo " ️ main is ${AHEAD} commit(s) ahead"
# Check for existing cascade PR
EXISTING=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
PR_NUMBER=""
if [ "$EXISTING_COUNT" -gt 0 ]; then
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
echo " ️ Reusing existing PR #${PR_NUMBER}"
else
# Create cascade PR
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
\"head\": \"main\",
\"base\": \"${BRANCH}\"
}" \
"${API}/pulls")
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
BODY=$(echo "$PR_RESPONSE" | sed '$d')
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
FAILED=$((FAILED + 1))
continue
fi
echo " ✅ Created PR #${PR_NUMBER}"
fi
# Try auto-merge
PR_DATA=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/pulls/${PR_NUMBER}")
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
if [ "$MERGEABLE" != "true" ]; then
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
continue
fi
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"Do\": \"merge\",
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
\"delete_branch_after_merge\": false
}" \
"${API}/pulls/${PR_NUMBER}/merge")
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
echo " ✅ Merged — ${BRANCH} is in sync"
SUCCESS=$((SUCCESS + 1))
else
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
CONFLICTS=$((CONFLICTS + 1))
fi
done
# Summary
echo ""
echo "════════════════════════════════════════"
echo " ✅ Merged: ${SUCCESS}"
echo " ⚠️ Conflicts: ${CONFLICTS}"
echo " ⏭️ Up to date: ${SKIPPED}"
echo " ❌ Failed: ${FAILED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
exit 1
fi
- run: echo "Cascade disabled — auto-release handles dev recreation"
+6 -6
View File
@@ -43,9 +43,9 @@ jobs:
- name: Clone MokoStandards
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
@@ -53,7 +53,7 @@ jobs:
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -346,7 +346,7 @@ jobs:
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -391,7 +391,7 @@ jobs:
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --optimize-autoloader
+9 -9
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
@@ -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:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Merged Branch Cleanup ==="
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
# List branches via API
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_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 ${GITEA_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:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
echo "=== Workflow Run Cleanup ==="
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 ${GITEA_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 ${GITEA_TOKEN}" \
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
DELETED=$((DELETED + 1))
done
+2 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
+73
View File
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
name: Create feature branch
runs-on: ubuntu-latest
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
# Build slug from title: lowercase, replace non-alnum with dash, trim
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
# Check dev branch exists
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${API}/branches/dev" 2>/dev/null || echo "000")
if [ "${DEV_EXISTS}" != "200" ]; then
echo "No dev branch -- skipping"
exit 0
fi
# Create branch from dev
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/branches" \
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
if [ "${HTTP}" = "201" ]; then
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
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 \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/comments" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
echo "Commented on issue #${ISSUE_NUM}"
else
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
fi
+3 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
@@ -18,6 +18,7 @@ on:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
- "Cascade Main → Dev"
types:
- completed
+508 -236
View File
@@ -1,236 +1,508 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
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 >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
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
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
rc)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="RC branch can only merge into 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for merge conflict markers
run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found in source files"
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "No conflict markers found"
- name: Detect platform
id: platform
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
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 >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Joomla JEXEC guard check
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
# Skip vendor, node_modules, and index.html stub files
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
# Check first 10 lines for JEXEC or JPATH guard
if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
echo "::error file=${file}::Missing JEXEC guard: ${file}"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
if [ "$ERRORS" -gt 0 ]; then
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "JEXEC guard: OK"
- name: Joomla directory listing protection
if: steps.platform.outputs.platform == 'joomla'
run: |
MISSING=0
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && exit 0
while IFS= read -r dir; do
if [ ! -f "${dir}/index.html" ]; then
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
MISSING=$((MISSING + 1))
fi
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
if [ "$MISSING" -gt 0 ]; then
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
fi
echo "Directory protection: ${MISSING} missing (advisory)"
- name: Joomla script file and asset checks
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
[ -z "$MANIFEST" ] && exit 0
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check scriptfile exists if declared
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
if [ -n "$SCRIPTFILE" ]; then
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
ERRORS=$((ERRORS + 1))
else
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
fi
fi
# Require joomla.asset.json and validate it
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ASSET_JSON" ]; then
echo "::error::joomla.asset.json not found — Joomla asset system is required"
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
echo "::error::joomla.asset.json is not valid JSON"
ERRORS=$((ERRORS + 1))
}
fi
echo "joomla.asset.json: valid"
fi
# Validate all XML files in src/ are well-formed
XML_ERRORS=0
if command -v php &> /dev/null; then
while IFS= read -r -d '' xmlfile; do
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
XML_ERRORS=$((XML_ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
fi
if [ "$XML_ERRORS" -gt 0 ]; then
echo "::error::${XML_ERRORS} XML file(s) are malformed"
ERRORS=$((ERRORS + 1))
else
echo "XML well-formedness: OK"
fi
[ "$ERRORS" -gt 0 ] && exit 1
echo "Joomla asset checks: OK"
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
# Block legacy raw/branch update server URLs on MokoGitea
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
if [ -n "$RAW_URLS" ]; then
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
echo "$RAW_URLS"
exit 1
fi
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Validate Joomla language files
if: steps.platform.outputs.platform == 'joomla'
run: |
ERRORS=0
WARNINGS=0
# Require both en-GB and en-US language directories
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$LANG_ROOT" ]; then
echo "No language/ directory found — skipping"
exit 0
fi
if [ ! -d "$LANG_ROOT/en-GB" ]; then
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
ERRORS=$((ERRORS + 1))
fi
if [ ! -d "$LANG_ROOT/en-US" ]; then
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
ERRORS=$((ERRORS + 1))
fi
# Check that en-GB and en-US have matching .ini files
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
[ ! -f "$GB_INI" ] && continue
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
if [ ! -f "$US_INI" ]; then
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
ERRORS=$((ERRORS + 1))
fi
done
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
[ ! -f "$US_INI" ] && continue
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
if [ ! -f "$GB_INI" ]; then
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
ERRORS=$((ERRORS + 1))
fi
done
fi
# Find all .ini language files
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
if [ -z "$INI_FILES" ]; then
echo "No .ini language files found"
[ "$ERRORS" -gt 0 ] && exit 1
exit 0
fi
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
for FILE in $INI_FILES; do
FNAME=$(basename "$FILE")
LINENUM=0
SEEN_KEYS=""
while IFS= read -r line || [ -n "$line" ]; do
LINENUM=$((LINENUM + 1))
# Skip empty lines and comments
[ -z "$line" ] && continue
echo "$line" | grep -qE '^\s*;' && continue
echo "$line" | grep -qE '^\s*$' && continue
# Must match KEY="VALUE" format
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
ERRORS=$((ERRORS + 1))
continue
fi
# Extract key and check for duplicates
KEY=$(echo "$line" | sed 's/=.*//')
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
ERRORS=$((ERRORS + 1))
fi
SEEN_KEYS="${SEEN_KEYS}
${KEY}"
done < "$FILE"
echo " ${FILE}: checked ${LINENUM} lines"
done
# Cross-check en-GB vs en-US key consistency
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
for GB_FILE in "$GB_DIR"/*.ini; do
[ ! -f "$GB_FILE" ] && continue
FNAME=$(basename "$GB_FILE")
US_FILE="$US_DIR/$FNAME"
[ ! -f "$US_FILE" ] && continue
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
# Keys in en-GB but not en-US
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_US" ]; then
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
# Keys in en-US but not en-GB
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
if [ -n "$MISSING_GB" ]; then
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
WARNINGS=$((WARNINGS + 1))
fi
done
fi
{
echo "### Language File Validation"
echo "| Metric | Count |"
echo "|---|---|"
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
echo "| Errors | ${ERRORS} |"
echo "| Warnings | ${WARNINGS} |"
} >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -gt 0 ]; then
echo "::error::Language validation failed with ${ERRORS} error(s)"
exit 1
fi
echo "Language files: OK (${WARNINGS} warning(s))"
- name: Check changelog has unreleased entry
run: |
if [ ! -f "CHANGELOG.md" ]; then
echo "::warning::No CHANGELOG.md found"
exit 0
fi
# Check for content under [Unreleased] section
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md missing [Unreleased] section"
exit 1
fi
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
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'
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."
File diff suppressed because it is too large Load Diff
+2 -18
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
@@ -80,19 +80,3 @@ jobs:
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
- name: Joomla version audit
if: always()
run: |
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
echo "$JOOMLA_SITES" > /tmp/sites.json
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
rm -f /tmp/sites.json
else
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
fi
env:
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
+161
View File
@@ -0,0 +1,161 @@
# Contributing to Moko Consulting Projects
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
## Branching Workflow
```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
```
### Step by step
1. **Create a feature branch** from `dev`:
```bash
git checkout dev && git pull
git checkout -b feature/my-change
```
2. **Work and commit** on your feature branch. Push to origin.
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
## Reporting Issues
Use the repository's issue tracker with the appropriate template.
---
*Moko Consulting <hello@mokoconsulting.tech>*
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
@@ -1,68 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @subpackage com_mokoog.api
* @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\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\ApiController;
class TagsController extends ApiController
{
/**
* The content type for JSON:API output.
*
* @var string
*/
protected $contentType = 'tags';
/**
* The default view for the API.
*
* @var string
*/
protected $default_view = 'tags';
/**
* Lookup an OG tag by content_type and content_id.
*
* GET /api/index.php/v1/mokoog/lookup/:content_type/:content_id
*
* @return static
*/
public function lookup(): static
{
$contentType = $this->input->getString('content_type', '');
$contentId = $this->input->getInt('content_id', 0);
if (empty($contentType) || $contentId <= 0) {
throw new \RuntimeException('content_type and content_id are required', 400);
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
->where($db->quoteName('content_id') . ' = ' . $contentId);
$db->setQuery($query);
$id = $db->loadResult();
if (!$id) {
throw new \RuntimeException('OG tag not found for ' . $contentType . ':' . $contentId, 404);
}
$this->input->set('id', $id);
return $this->displayItem();
}
}
@@ -1,66 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @subpackage com_mokoog.api
* @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\Api\View\Tags;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
class JsonapiView extends BaseApiView
{
/**
* The fields to render in the API response.
*
* Whitelist of fields from #__mokoog_tags that are safe to expose.
*
* @var array
*/
protected $fieldsToRenderItem = [
'id',
'content_type',
'content_id',
'og_title',
'og_description',
'og_image',
'og_type',
'seo_title',
'meta_description',
'robots',
'canonical_url',
'language',
'published',
'created',
'modified',
];
/**
* The fields to render in list responses.
*
* @var array
*/
protected $fieldsToRenderList = [
'id',
'content_type',
'content_id',
'og_title',
'og_description',
'og_image',
'og_type',
'seo_title',
'meta_description',
'robots',
'canonical_url',
'language',
'published',
'created',
'modified',
];
}
-33
View File
@@ -70,37 +70,4 @@
<option value="0">JUNPUBLISHED</option>
</field>
</fieldset>
<fieldset name="seo" label="SEO Meta Tags">
<field
name="seo_title"
type="text"
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
filter="string"
maxlength="70"
/>
<field
name="meta_description"
type="textarea"
label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
filter="string"
rows="3"
maxlength="200"
/>
<field
name="robots"
type="text"
label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
filter="string"
/>
<field
name="canonical_url"
type="url"
label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
filter="url"
/>
</fieldset>
</form>
@@ -13,45 +13,4 @@ COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type"
COM_MOKOOG_HEADING_CONTENT_ID="Content ID"
COM_MOKOOG_HEADING_OG_TITLE="OG Title"
COM_MOKOOG_HEADING_IMAGE="Image"
COM_MOKOOG_HEADING_SEO="SEO"
COM_MOKOOG_HEADING_DEBUG="Debug"
COM_MOKOOG_HEADING_MODIFIED="Modified"
COM_MOKOOG_SEO_OK="OK"
COM_MOKOOG_SEO_MISSING_DESC="No meta description"
COM_MOKOOG_SEO_TITLE_LONG="SEO title too long"
COM_MOKOOG_SEO_NOINDEX="noindex"
COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type"
COM_MOKOOG_FIELD_CONTENT_ID="Content ID"
COM_MOKOOG_FIELD_OG_TITLE="OG Title"
COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing."
COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing."
COM_MOKOOG_FIELD_OG_IMAGE="OG Image"
COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing."
COM_MOKOOG_FIELD_OG_TYPE="OG Type"
COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type."
COM_MOKOOG_FILTER_SEARCH="Search OG titles"
COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -"
COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending"
COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending"
COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending"
COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending"
COM_MOKOOG_TOOLBAR_BATCH_GENERATE="Batch Generate"
COM_MOKOOG_BATCH_TITLE="Batch OG Tag Generation"
COM_MOKOOG_BATCH_COUNTING="Counting articles without OG tags..."
COM_MOKOOG_BATCH_NONE="All articles already have OG tags."
COM_MOKOOG_BATCH_FOUND="articles found without OG tags."
COM_MOKOOG_BATCH_PROCESSED="processed"
COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!"
COM_MOKOOG_BATCH_ERROR="Error:"
COM_MOKOOG_TOOLBAR_EXPORT="Export CSV"
COM_MOKOOG_TOOLBAR_IMPORT="Import CSV"
COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded."
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
@@ -13,45 +13,4 @@ COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type"
COM_MOKOOG_HEADING_CONTENT_ID="Content ID"
COM_MOKOOG_HEADING_OG_TITLE="OG Title"
COM_MOKOOG_HEADING_IMAGE="Image"
COM_MOKOOG_HEADING_SEO="SEO"
COM_MOKOOG_HEADING_DEBUG="Debug"
COM_MOKOOG_HEADING_MODIFIED="Modified"
COM_MOKOOG_SEO_OK="OK"
COM_MOKOOG_SEO_MISSING_DESC="No meta description"
COM_MOKOOG_SEO_TITLE_LONG="SEO title too long"
COM_MOKOOG_SEO_NOINDEX="noindex"
COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type"
COM_MOKOOG_FIELD_CONTENT_ID="Content ID"
COM_MOKOOG_FIELD_OG_TITLE="OG Title"
COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing."
COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing."
COM_MOKOOG_FIELD_OG_IMAGE="OG Image"
COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing."
COM_MOKOOG_FIELD_OG_TYPE="OG Type"
COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type."
COM_MOKOOG_FILTER_SEARCH="Search OG titles"
COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -"
COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending"
COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending"
COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending"
COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending"
COM_MOKOOG_TOOLBAR_BATCH_GENERATE="Batch Generate"
COM_MOKOOG_BATCH_TITLE="Batch OG Tag Generation"
COM_MOKOOG_BATCH_COUNTING="Counting articles without OG tags..."
COM_MOKOOG_BATCH_NONE="All articles already have OG tags."
COM_MOKOOG_BATCH_FOUND="articles found without OG tags."
COM_MOKOOG_BATCH_PROCESSED="processed"
COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!"
COM_MOKOOG_BATCH_ERROR="Error:"
COM_MOKOOG_TOOLBAR_EXPORT="Export CSV"
COM_MOKOOG_TOOLBAR_IMPORT="Import CSV"
COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded."
COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file."
COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped."
+1 -8
View File
@@ -8,7 +8,7 @@
-->
<extension type="component" method="upgrade">
<name>com_mokoog</name>
<version>01.00.00-dev</version>
<version>01.00.00</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -42,7 +42,6 @@
<filename>provider.php</filename>
</files>
<files folder="src">
<folder>ContentType</folder>
<folder>Controller</folder>
<folder>Extension</folder>
<folder>Model</folder>
@@ -69,10 +68,4 @@
<menu link="option=com_mokoog&amp;view=tags">COM_MOKOOG_SUBMENU_TAGS</menu>
</submenu>
</administration>
<api>
<files folder="api">
<folder>src</folder>
</files>
</api>
</extension>
@@ -12,15 +12,10 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` (
`og_description` TEXT NOT NULL,
`og_image` VARCHAR(512) NOT NULL DEFAULT '',
`og_type` VARCHAR(50) NOT NULL DEFAULT 'article',
`seo_title` VARCHAR(70) NOT NULL DEFAULT '',
`meta_description` VARCHAR(200) NOT NULL DEFAULT '',
`robots` VARCHAR(100) NOT NULL DEFAULT '',
`canonical_url` VARCHAR(512) NOT NULL DEFAULT '',
`language` CHAR(7) NOT NULL DEFAULT '*',
`published` TINYINT(1) NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`),
UNIQUE KEY `idx_content` (`content_type`, `content_id`),
KEY `idx_published` (`published`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -1,9 +0,0 @@
--
-- MokoOpenGraph 01.01.00 — Add SEO meta management columns
--
ALTER TABLE `#__mokoog_tags`
ADD COLUMN `seo_title` VARCHAR(70) NOT NULL DEFAULT '' AFTER `og_type`,
ADD COLUMN `meta_description` VARCHAR(200) NOT NULL DEFAULT '' AFTER `seo_title`,
ADD COLUMN `robots` VARCHAR(100) NOT NULL DEFAULT '' AFTER `meta_description`,
ADD COLUMN `canonical_url` VARCHAR(512) NOT NULL DEFAULT '' AFTER `robots`;
@@ -1,10 +0,0 @@
--
-- MokoOpenGraph 01.02.00 — Add multilingual OG tag support
--
ALTER TABLE `#__mokoog_tags`
ADD COLUMN `language` CHAR(7) NOT NULL DEFAULT '*' AFTER `canonical_url`;
ALTER TABLE `#__mokoog_tags`
DROP INDEX `idx_content`,
ADD UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`);
@@ -1,60 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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\ContentType;
defined('_JEXEC') or die;
interface ContentTypeInterface
{
/**
* Check if this adapter can handle the given component/view.
*
* @param string $option Component option (e.g. com_virtuemart)
* @param string $view View name (e.g. productdetails)
*
* @return bool
*/
public function canHandle(string $option, string $view): bool;
/**
* Get the content type identifier for database storage.
*
* @return string
*/
public function getContentType(): string;
/**
* Get the title for the content item.
*
* @param int $id Content item ID
*
* @return string
*/
public function getTitle(int $id): string;
/**
* Get a description for the content item.
*
* @param int $id Content item ID
*
* @return string
*/
public function getDescription(int $id): string;
/**
* Get the primary image for the content item.
*
* @param int $id Content item ID
*
* @return string Image path relative to JPATH_ROOT, or empty string
*/
public function getImage(int $id): string;
}
@@ -1,76 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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\ContentType;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class HikaShopAdapter implements ContentTypeInterface
{
public function canHandle(string $option, string $view): bool
{
return $option === 'com_hikashop' && $view === 'product';
}
public function getContentType(): string
{
return 'com_hikashop';
}
public function getTitle(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('product_name'))
->from($db->quoteName('#__hikashop_product'))
->where($db->quoteName('product_id') . ' = ' . $id);
$db->setQuery($query);
return $db->loadResult() ?: '';
}
public function getDescription(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('product_description'))
->from($db->quoteName('#__hikashop_product'))
->where($db->quoteName('product_id') . ' = ' . $id);
$db->setQuery($query);
$text = $db->loadResult() ?: '';
$text = strip_tags($text);
$text = trim(preg_replace('/\s+/', ' ', $text));
if (\strlen($text) > 160) {
$text = mb_substr($text, 0, 157) . '...';
}
return $text;
}
public function getImage(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('f.file_path'))
->from($db->quoteName('#__hikashop_file', 'f'))
->where($db->quoteName('f.file_ref_id') . ' = ' . $id)
->where($db->quoteName('f.file_type') . ' = ' . $db->quote('product'))
->order($db->quoteName('f.file_ordering') . ' ASC');
$db->setQuery($query, 0, 1);
return $db->loadResult() ?: '';
}
}
@@ -1,73 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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\ContentType;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class K2Adapter implements ContentTypeInterface
{
public function canHandle(string $option, string $view): bool
{
return $option === 'com_k2' && $view === 'item';
}
public function getContentType(): string
{
return 'com_k2';
}
public function getTitle(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('title'))
->from($db->quoteName('#__k2_items'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
return $db->loadResult() ?: '';
}
public function getDescription(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('introtext'))
->from($db->quoteName('#__k2_items'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$text = $db->loadResult() ?: '';
$text = strip_tags($text);
$text = trim(preg_replace('/\s+/', ' ', $text));
if (\strlen($text) > 160) {
$text = mb_substr($text, 0, 157) . '...';
}
return $text;
}
public function getImage(int $id): string
{
// K2 stores images as media/k2/items/cache/{md5}_L.jpg
$imagePath = 'media/k2/items/cache/' . md5('Image' . $id) . '_L.jpg';
if (is_file(JPATH_ROOT . '/' . $imagePath)) {
return $imagePath;
}
return '';
}
}
@@ -1,82 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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\ContentType;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class VirtueMartAdapter implements ContentTypeInterface
{
public function canHandle(string $option, string $view): bool
{
return $option === 'com_virtuemart' && $view === 'productdetails';
}
public function getContentType(): string
{
return 'com_virtuemart';
}
public function getTitle(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('product_name'))
->from($db->quoteName('#__virtuemart_products_' . $this->getLangTag()))
->where($db->quoteName('virtuemart_product_id') . ' = ' . $id);
$db->setQuery($query);
return $db->loadResult() ?: '';
}
public function getDescription(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('product_s_desc'))
->from($db->quoteName('#__virtuemart_products_' . $this->getLangTag()))
->where($db->quoteName('virtuemart_product_id') . ' = ' . $id);
$db->setQuery($query);
$desc = $db->loadResult() ?: '';
return strip_tags($desc);
}
public function getImage(int $id): string
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('m.file_url'))
->from($db->quoteName('#__virtuemart_product_medias', 'pm'))
->join('INNER', $db->quoteName('#__virtuemart_medias', 'm') . ' ON ' . $db->quoteName('m.virtuemart_media_id') . ' = ' . $db->quoteName('pm.virtuemart_media_id'))
->where($db->quoteName('pm.virtuemart_product_id') . ' = ' . $id)
->order($db->quoteName('pm.ordering') . ' ASC');
$db->setQuery($query, 0, 1);
return $db->loadResult() ?: '';
}
/**
* Get the VirtueMart language table suffix.
*
* @return string
*/
private function getLangTag(): string
{
$lang = Factory::getLanguage()->getTag();
return strtolower(str_replace('-', '_', $lang));
}
}
@@ -1,169 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Response\JsonResponse;
use Joomla\CMS\Session\Session;
class BatchController extends BaseController
{
/**
* Count the total articles eligible for batch generation.
*
* @return void
*/
public function count(): void
{
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('COUNT(*)')
->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');
$db->setQuery($query);
$total = (int) $db->loadResult();
echo new JsonResponse(['total' => $total]);
Factory::getApplication()->close();
}
/**
* Process a chunk of articles for batch OG generation.
*
* @return void
*/
public function process(): void
{
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$offset = $app->getInput()->getInt('offset', 0);
$limit = $app->getInput()->getInt('limit', 50);
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'c.id', 'c.title', 'c.metadesc', 'c.introtext', 'c.fulltext', 'c.images',
]))
->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') . ' ASC');
$db->setQuery($query, $offset, $limit);
$articles = $db->loadObjectList();
$created = 0;
$now = Factory::getDate()->toSql();
foreach ($articles as $article) {
$ogTitle = $article->title;
$ogDescription = $this->extractDescription($article);
$ogImage = $this->extractImage($article);
$record = (object) [
'content_type' => 'com_content',
'content_id' => (int) $article->id,
'og_title' => $ogTitle,
'og_description' => $ogDescription,
'og_image' => $ogImage,
'og_type' => 'article',
'seo_title' => '',
'meta_description' => $article->metadesc ?: '',
'robots' => '',
'canonical_url' => '',
'published' => 1,
'created' => $now,
'modified' => $now,
];
$db->insertObject('#__mokoog_tags', $record);
$created++;
}
echo new JsonResponse([
'created' => $created,
'offset' => $offset,
'processed' => $offset + $created,
]);
$app->close();
}
/**
* Extract a description from article content.
*
* @param object $article Article record
*
* @return string
*/
private function extractDescription(object $article): string
{
// Prefer meta description if set
if (!empty($article->metadesc)) {
return $article->metadesc;
}
// Fall back to intro text
$text = $article->introtext ?: $article->fulltext;
$text = strip_tags($text);
$text = trim(preg_replace('/\s+/', ' ', $text));
if (\strlen($text) > 160) {
$text = mb_substr($text, 0, 157) . '...';
}
return $text;
}
/**
* Extract the best image from article data.
*
* @param object $article Article record
*
* @return string
*/
private function extractImage(object $article): string
{
if (!empty($article->images)) {
$images = json_decode($article->images, true);
if (!empty($images['image_fulltext'])) {
return $images['image_fulltext'];
}
if (!empty($images['image_intro'])) {
return $images['image_intro'];
}
}
return '';
}
}
@@ -1,188 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
class ImportExportController extends BaseController
{
/**
* Export all OG tags as CSV.
*
* @return void
*/
public function export(): void
{
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$db = Factory::getDbo();
// Join with #__content to get article titles for reference
$query = $db->getQuery(true)
->select([
$db->quoteName('t.content_type'),
$db->quoteName('t.content_id'),
'COALESCE(' . $db->quoteName('c.title') . ', ' . $db->quote('') . ') AS ' . $db->quoteName('article_title'),
$db->quoteName('t.og_title'),
$db->quoteName('t.og_description'),
$db->quoteName('t.og_image'),
$db->quoteName('t.og_type'),
$db->quoteName('t.seo_title'),
$db->quoteName('t.meta_description'),
$db->quoteName('t.robots'),
$db->quoteName('t.canonical_url'),
])
->from($db->quoteName('#__mokoog_tags', 't'))
->leftJoin(
$db->quoteName('#__content', 'c')
. ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content')
. ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id')
)
->order($db->quoteName('t.content_type') . ', ' . $db->quoteName('t.content_id'));
$db->setQuery($query);
$rows = $db->loadAssocList();
// Send CSV headers
$app->setHeader('Content-Type', 'text/csv; charset=utf-8');
$app->setHeader('Content-Disposition', 'attachment; filename="mokoog_tags_export.csv"');
$app->sendHeaders();
$output = fopen('php://output', 'w');
// Header row
fputcsv($output, [
'content_type', 'content_id', 'article_title',
'og_title', 'og_description', 'og_image', 'og_type',
'seo_title', 'meta_description', 'robots', 'canonical_url',
]);
foreach ($rows as $row) {
fputcsv($output, $row);
}
fclose($output);
$app->close();
}
/**
* Import OG tags from uploaded CSV.
*
* @return void
*/
public function import(): void
{
Session::checkToken() || jexit(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$input = $app->getInput();
$files = $input->files->get('jform', [], 'array');
if (empty($files['csv_file']['tmp_name'])) {
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_NO_FILE'), 'error');
$app->redirect('index.php?option=com_mokoog&view=tags');
return;
}
$tmpFile = $files['csv_file']['tmp_name'];
$handle = fopen($tmpFile, 'r');
if (!$handle) {
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_READ_ERROR'), 'error');
$app->redirect('index.php?option=com_mokoog&view=tags');
return;
}
$db = Factory::getDbo();
$header = fgetcsv($handle);
$created = 0;
$updated = 0;
$skipped = 0;
$now = Factory::getDate()->toSql();
while (($row = fgetcsv($handle)) !== false) {
if (\count($row) < 7) {
$skipped++;
continue;
}
$contentType = trim($row[0]);
$contentId = (int) $row[1];
// $row[2] = article_title (informational, skip)
$ogTitle = trim($row[3] ?? '');
$ogDescription = trim($row[4] ?? '');
$ogImage = trim($row[5] ?? '');
$ogType = trim($row[6] ?? 'article');
$seoTitle = trim($row[7] ?? '');
$metaDesc = trim($row[8] ?? '');
$robots = trim($row[9] ?? '');
$canonicalUrl = trim($row[10] ?? '');
if (empty($contentType) || $contentId <= 0) {
$skipped++;
continue;
}
// Check for existing record
$query = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
->where($db->quoteName('content_id') . ' = ' . $contentId);
$db->setQuery($query);
$existingId = $db->loadResult();
$record = (object) [
'content_type' => $contentType,
'content_id' => $contentId,
'og_title' => $ogTitle,
'og_description' => $ogDescription,
'og_image' => $ogImage,
'og_type' => $ogType,
'seo_title' => $seoTitle,
'meta_description' => $metaDesc,
'robots' => $robots,
'canonical_url' => $canonicalUrl,
'published' => 1,
'modified' => $now,
];
if ($existingId) {
$record->id = $existingId;
$db->updateObject('#__mokoog_tags', $record, 'id');
$updated++;
} else {
$record->created = $now;
$db->insertObject('#__mokoog_tags', $record);
$created++;
}
}
fclose($handle);
$app->enqueueMessage(
Text::sprintf('COM_MOKOOG_IMPORT_RESULT', $created, $updated, $skipped),
'success'
);
$app->redirect('index.php?option=com_mokoog&view=tags');
}
}
@@ -1,68 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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\Factory;
use Joomla\CMS\MVC\Model\AdminModel;
class TagModel extends AdminModel
{
/**
* Get the form for the item.
*
* @param array $data Form data
* @param bool $loadData Load data from state
*
* @return \Joomla\CMS\Form\Form|false
*/
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm(
'com_mokoog.tag',
'tag',
['control' => 'jform', 'load_data' => $loadData]
);
return $form ?: false;
}
/**
* Load the form data.
*
* @return object
*/
protected function loadFormData(): object
{
$data = Factory::getApplication()->getUserState('com_mokoog.edit.tag.data', []);
if (empty($data)) {
$data = $this->getItem();
}
return $data;
}
/**
* Get the table class name.
*
* @param string $name Table name
* @param string $prefix Table prefix
* @param array $options Table options
*
* @return \Joomla\CMS\Table\Table
*/
public function getTable($name = 'Tag', $prefix = 'Administrator', $options = [])
{
return parent::getTable($name, $prefix, $options);
}
}
@@ -39,20 +39,6 @@ class HtmlView extends BaseHtmlView
*/
protected $state;
/**
* The filter form.
*
* @var \Joomla\CMS\Form\Form|null
*/
public $filterForm;
/**
* The active filters.
*
* @var array
*/
public $activeFilters = [];
/**
* Display the view.
*
@@ -62,11 +48,9 @@ class HtmlView extends BaseHtmlView
*/
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
$this->filterForm = $this->get('FilterForm');
$this->activeFilters = $this->get('ActiveFilters');
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
$this->addToolbar();
@@ -81,8 +65,6 @@ class HtmlView extends BaseHtmlView
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOOG_TAGS_TITLE'), 'bookmark');
ToolbarHelper::custom('batch.generate', 'refresh', '', 'COM_MOKOOG_TOOLBAR_BATCH_GENERATE', false);
ToolbarHelper::custom('importexport.export', 'download', '', 'COM_MOKOOG_TOOLBAR_EXPORT', false);
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete');
ToolbarHelper::preferences('com_mokoog');
}
@@ -14,18 +14,14 @@ use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\Session\Session;
/** @var \Joomla\Component\MokoOG\Administrator\View\Tags\HtmlView $this */
$token = Session::getFormToken();
?>
<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">
<div id="j-main-container" class="j-main-container">
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
@@ -54,15 +50,9 @@ $token = Session::getFormToken();
<th scope="col" class="w-10">
<?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?>
</th>
<th scope="col" class="w-10">
<?php echo Text::_('COM_MOKOOG_HEADING_SEO'); ?>
</th>
<th scope="col" class="w-10">
<?php echo Text::_('JSTATUS'); ?>
</th>
<th scope="col" class="w-10">
<?php echo Text::_('COM_MOKOOG_HEADING_DEBUG'); ?>
</th>
<th scope="col" class="w-10">
<?php echo Text::_('COM_MOKOOG_HEADING_MODIFIED'); ?>
</th>
@@ -93,50 +83,9 @@ $token = Session::getFormToken();
<span class="icon-minus-circle text-muted" aria-hidden="true"></span>
<?php endif; ?>
</td>
<td>
<?php
$seoIssues = [];
if (empty($item->meta_description)) {
$seoIssues[] = Text::_('COM_MOKOOG_SEO_MISSING_DESC');
}
if (!empty($item->seo_title) && \strlen($item->seo_title) > 60) {
$seoIssues[] = Text::_('COM_MOKOOG_SEO_TITLE_LONG');
}
if (!empty($item->robots) && str_contains($item->robots, 'noindex')) {
$seoIssues[] = Text::_('COM_MOKOOG_SEO_NOINDEX');
}
if (empty($seoIssues)) : ?>
<span class="badge bg-success"><?php echo Text::_('COM_MOKOOG_SEO_OK'); ?></span>
<?php else : ?>
<?php foreach ($seoIssues as $issue) : ?>
<span class="badge bg-warning text-dark"><?php echo $issue; ?></span>
<?php endforeach; ?>
<?php endif; ?>
</td>
<td>
<?php echo $item->published ? Text::_('JPUBLISHED') : Text::_('JUNPUBLISHED'); ?>
</td>
<td class="mokoog-debug-links">
<?php
// Build frontend URL for this content item
if ($item->content_type === 'com_content') {
$debugUrl = Uri::root() . 'index.php?option=com_content&view=article&id=' . (int) $item->content_id;
} elseif ($item->content_type === 'menu') {
$debugUrl = Uri::root() . 'index.php?Itemid=' . (int) $item->content_id;
} elseif ($item->content_type === 'com_content.category') {
$debugUrl = Uri::root() . 'index.php?option=com_content&view=category&id=' . (int) $item->content_id;
} else {
$debugUrl = Uri::root();
}
?>
<a href="https://developers.facebook.com/tools/debug/?q=<?php echo urlencode($debugUrl); ?>" target="_blank" rel="noopener" title="Facebook Debugger" class="btn btn-sm btn-outline-primary">FB</a>
<a href="https://www.linkedin.com/post-inspector/inspect/<?php echo urlencode($debugUrl); ?>" target="_blank" rel="noopener" title="LinkedIn Inspector" class="btn btn-sm btn-outline-info">LI</a>
<a href="https://search.google.com/test/rich-results?url=<?php echo urlencode($debugUrl); ?>" target="_blank" rel="noopener" title="Google Rich Results" class="btn btn-sm btn-outline-success">G</a>
</td>
<td>
<?php echo HTMLHelper::_('date', $item->modified, Text::_('DATE_FORMAT_LC4')); ?>
</td>
@@ -158,85 +107,3 @@ $token = Session::getFormToken();
</div>
</div>
</form>
<!-- Batch Generation Progress -->
<div id="mokoog-batch-panel" style="display:none;" class="card mt-3">
<div class="card-body">
<h4><?php echo Text::_('COM_MOKOOG_BATCH_TITLE'); ?></h4>
<div class="progress mb-2">
<div id="mokoog-batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%">0%</div>
</div>
<p id="mokoog-batch-status"></p>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Intercept the batch.generate toolbar button
var origSubmitbutton = Joomla.submitbutton;
Joomla.submitbutton = function(task) {
if (task === 'batch.generate') {
mokoogBatchGenerate();
return;
}
if (origSubmitbutton) {
origSubmitbutton(task);
}
};
function mokoogBatchGenerate() {
var panel = document.getElementById('mokoog-batch-panel');
var bar = document.getElementById('mokoog-batch-bar');
var status = document.getElementById('mokoog-batch-status');
var token = '<?php echo $token; ?>';
var chunkSize = 50;
panel.style.display = 'block';
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COUNTING', true); ?>';
// Step 1: Count eligible articles
fetch('index.php?option=com_mokoog&task=batch.count&format=json&' + token + '=1')
.then(function(r) { return r.json(); })
.then(function(resp) {
var total = resp.data.total;
if (total === 0) {
bar.style.width = '100%';
bar.textContent = '100%';
bar.classList.remove('progress-bar-animated');
bar.classList.add('bg-success');
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_NONE', true); ?>';
return;
}
status.textContent = total + ' <?php echo Text::_('COM_MOKOOG_BATCH_FOUND', true); ?>';
processChunk(0, total, chunkSize, token, bar, status);
})
.catch(function(err) {
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message;
});
}
function processChunk(offset, total, chunkSize, token, bar, status) {
fetch('index.php?option=com_mokoog&task=batch.process&format=json&offset=' + offset + '&limit=' + chunkSize + '&' + token + '=1')
.then(function(r) { return r.json(); })
.then(function(resp) {
var processed = resp.data.processed;
var pct = Math.min(100, Math.round((processed / total) * 100));
bar.style.width = pct + '%';
bar.textContent = pct + '%';
status.textContent = processed + ' / ' + total + ' <?php echo Text::_('COM_MOKOOG_BATCH_PROCESSED', true); ?>';
if (processed < total) {
processChunk(processed, total, chunkSize, token, bar, status);
} else {
bar.classList.remove('progress-bar-animated');
bar.classList.add('bg-success');
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COMPLETE', true); ?> ' + total + ' articles.';
setTimeout(function() { location.reload(); }, 2000);
}
})
.catch(function(err) {
status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message;
});
}
});
</script>
@@ -50,48 +50,5 @@
<option value="video.other">Video</option>
</field>
</fieldset>
<fieldset name="mokoog_seo" label="PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL"
description="PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC">
<field
name="seo_title"
type="text"
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
filter="string"
maxlength="70"
/>
<field
name="meta_description"
type="textarea"
label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
filter="string"
rows="3"
maxlength="200"
/>
<field
name="robots"
type="list"
label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
default=""
multiple="true"
>
<option value="">PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT</option>
<option value="noindex">noindex</option>
<option value="nofollow">nofollow</option>
<option value="nosnippet">nosnippet</option>
<option value="noarchive">noarchive</option>
<option value="noimageindex">noimageindex</option>
</field>
<field
name="canonical_url"
type="url"
label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
filter="url"
validate="url"
/>
</fieldset>
</fields>
</form>
@@ -13,16 +13,3 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image"
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image."
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title"
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom <title> tag. 50-60 characters recommended. Leave blank to use the default page title."
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default."
PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive"
PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)."
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
@@ -13,16 +13,3 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image"
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image."
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title"
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom <title> tag. 50-60 characters recommended. Leave blank to use the default page title."
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default."
PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive"
PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)."
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
@@ -1,104 +0,0 @@
/**
* @package MokoOpenGraph
* @subpackage plg_content_mokoog
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
.mokoog-preview-wrapper {
margin: 15px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.mokoog-preview-heading {
margin: 0 0 12px;
font-size: 14px;
color: #666;
}
.mokoog-platform-label {
display: block;
color: #999;
text-transform: uppercase;
font-size: 11px;
font-weight: 600;
margin-top: 16px;
margin-bottom: 4px;
}
.mokoog-platform-label:first-of-type {
margin-top: 0;
}
.mokoog-card {
overflow: hidden;
max-width: 500px;
background: #fff;
}
.mokoog-card-fb {
border: 1px solid #ddd;
border-radius: 3px;
}
.mokoog-card-tw {
border: 1px solid #cfd9de;
border-radius: 16px;
}
.mokoog-card-img {
height: 260px;
background: #e4e6eb center / cover no-repeat;
}
.mokoog-card-body {
padding: 10px 12px;
border-top: 1px solid #ddd;
}
.mokoog-card-tw .mokoog-card-body {
border-top-color: #cfd9de;
}
.mokoog-card-domain {
font-size: 11px;
color: #65676b;
text-transform: uppercase;
}
.mokoog-card-tw .mokoog-card-domain {
font-size: 13px;
text-transform: none;
margin-top: 4px;
}
.mokoog-card-title {
font-size: 16px;
font-weight: 600;
color: #1d2129;
margin: 3px 0 2px;
line-height: 1.3;
}
.mokoog-card-tw .mokoog-card-title {
font-size: 15px;
font-weight: 700;
color: #0f1419;
}
.mokoog-card-desc {
font-size: 14px;
color: #65676b;
line-height: 1.4;
max-height: 2.8em;
overflow: hidden;
}
.mokoog-card-tw .mokoog-card-desc {
font-size: 15px;
color: #536471;
margin-top: 2px;
}
@@ -1,20 +0,0 @@
{
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
"name": "plg_content_mokoog",
"version": "01.00.00",
"description": "MokoOpenGraph Content Plugin Assets",
"license": "GPL-3.0-or-later",
"assets": [
{
"name": "plg_content_mokoog.preview",
"type": "style",
"uri": "plg_content_mokoog/css/preview.css"
},
{
"name": "plg_content_mokoog.preview",
"type": "script",
"uri": "plg_content_mokoog/js/preview.js",
"dependencies": ["core"]
}
]
}
@@ -1,170 +0,0 @@
/**
* @package MokoOpenGraph
* @subpackage plg_content_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*
* Live social sharing preview for article/menu item editor.
*/
document.addEventListener('DOMContentLoaded', function () {
'use strict';
var fields = {
ogTitle: document.getElementById('jform_mokoog_og_title'),
ogDesc: document.getElementById('jform_mokoog_og_description'),
ogImage: document.getElementById('jform_mokoog_og_image'),
articleTitle: document.getElementById('jform_title'),
metaDesc: document.getElementById('jform_metadesc')
};
// Find the mokoog fieldset and insert preview after it
var fieldset = document.querySelector('[data-showon-id="mokoog"]') ||
document.getElementById('attrib-mokoog') ||
document.querySelector('fieldset.mokoog') ||
document.querySelector('[id*="mokoog"]');
if (!fieldset) {
return;
}
// Build preview DOM safely (no innerHTML with user data)
var preview = document.createElement('div');
preview.id = 'mokoog-preview';
var wrapper = document.createElement('div');
wrapper.className = 'mokoog-preview-wrapper';
var heading = document.createElement('h4');
heading.className = 'mokoog-preview-heading';
heading.textContent = 'Social Sharing Preview';
wrapper.appendChild(heading);
// Facebook preview card
var fbLabel = document.createElement('small');
fbLabel.className = 'mokoog-platform-label';
fbLabel.textContent = 'Facebook';
wrapper.appendChild(fbLabel);
var fbCard = document.createElement('div');
fbCard.className = 'mokoog-card mokoog-card-fb';
var fbImg = document.createElement('div');
fbImg.id = 'mokoog-fb-img';
fbImg.className = 'mokoog-card-img';
fbCard.appendChild(fbImg);
var fbBody = document.createElement('div');
fbBody.className = 'mokoog-card-body';
var fbDomain = document.createElement('div');
fbDomain.id = 'mokoog-fb-domain';
fbDomain.className = 'mokoog-card-domain';
fbBody.appendChild(fbDomain);
var fbTitle = document.createElement('div');
fbTitle.id = 'mokoog-fb-title';
fbTitle.className = 'mokoog-card-title';
fbBody.appendChild(fbTitle);
var fbDesc = document.createElement('div');
fbDesc.id = 'mokoog-fb-desc';
fbDesc.className = 'mokoog-card-desc';
fbBody.appendChild(fbDesc);
fbCard.appendChild(fbBody);
wrapper.appendChild(fbCard);
// Twitter preview card
var twLabel = document.createElement('small');
twLabel.className = 'mokoog-platform-label';
twLabel.textContent = 'Twitter / X';
wrapper.appendChild(twLabel);
var twCard = document.createElement('div');
twCard.className = 'mokoog-card mokoog-card-tw';
var twImg = document.createElement('div');
twImg.id = 'mokoog-tw-img';
twImg.className = 'mokoog-card-img';
twCard.appendChild(twImg);
var twBody = document.createElement('div');
twBody.className = 'mokoog-card-body';
var twTitle = document.createElement('div');
twTitle.id = 'mokoog-tw-title';
twTitle.className = 'mokoog-card-title';
twBody.appendChild(twTitle);
var twDesc = document.createElement('div');
twDesc.id = 'mokoog-tw-desc';
twDesc.className = 'mokoog-card-desc';
twBody.appendChild(twDesc);
var twDomain = document.createElement('div');
twDomain.id = 'mokoog-tw-domain';
twDomain.className = 'mokoog-card-domain';
twBody.appendChild(twDomain);
twCard.appendChild(twBody);
wrapper.appendChild(twCard);
preview.appendChild(wrapper);
fieldset.parentNode.insertBefore(preview, fieldset.nextSibling);
var domain = window.location.hostname;
function updatePreview() {
var title = (fields.ogTitle && fields.ogTitle.value) ||
(fields.articleTitle && fields.articleTitle.value) || 'Page Title';
var desc = (fields.ogDesc && fields.ogDesc.value) ||
(fields.metaDesc && fields.metaDesc.value) || 'Page description will appear here...';
var img = '';
if (fields.ogImage) {
img = fields.ogImage.value;
}
if (title.length > 65) title = title.substring(0, 62) + '...';
if (desc.length > 160) desc = desc.substring(0, 157) + '...';
// Facebook
document.getElementById('mokoog-fb-title').textContent = title;
document.getElementById('mokoog-fb-desc').textContent = desc;
document.getElementById('mokoog-fb-domain').textContent = domain;
var fbImgEl = document.getElementById('mokoog-fb-img');
if (img) {
fbImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
fbImgEl.style.display = '';
} else {
fbImgEl.style.display = 'none';
}
// Twitter
document.getElementById('mokoog-tw-title').textContent = title;
document.getElementById('mokoog-tw-desc').textContent = desc;
document.getElementById('mokoog-tw-domain').textContent = domain;
var twImgEl = document.getElementById('mokoog-tw-img');
if (img) {
twImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
twImgEl.style.display = '';
} else {
twImgEl.style.display = 'none';
}
}
Object.values(fields).forEach(function (el) {
if (el) {
el.addEventListener('input', updatePreview);
el.addEventListener('change', updatePreview);
}
});
if (fields.ogImage) {
var observer = new MutationObserver(updatePreview);
observer.observe(fields.ogImage, { attributes: true, attributeFilter: ['value'] });
}
updatePreview();
});
+1 -7
View File
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoOpenGraph</name>
<version>01.00.00-dev</version>
<version>01.00.00</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -27,12 +27,6 @@
<folder>language</folder>
</files>
<media destination="plg_content_mokoog" folder="media">
<filename>joomla.asset.json</filename>
<folder>js</folder>
<folder>css</folder>
</media>
<languages>
<language tag="en-GB">language/en-GB/plg_content_mokoog.ini</language>
<language tag="en-GB">language/en-GB/plg_content_mokoog.sys.ini</language>
@@ -56,11 +56,10 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$formName = $form->getName();
// Add OG fields to article, menu item, and category edit forms
// Add OG fields to article and menu item edit forms
$supportedForms = [
'com_content.article',
'com_menus.item',
'com_categories.categorycom_content',
];
if (!\in_array($formName, $supportedForms, true)) {
@@ -72,12 +71,6 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
Form::addFormPath($formPath);
$form->loadFile('mokoog', false);
// Load live preview assets
$wa = $this->getApplication()->getDocument()->getWebAssetManager();
$wa->getRegistry()->addRegistryFile('media/plg_content_mokoog/joomla.asset.json');
$wa->useStyle('plg_content_mokoog.preview');
$wa->useScript('plg_content_mokoog.preview');
// If editing an existing item, load saved OG data
$id = 0;
@@ -88,12 +81,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
}
if ($id > 0) {
$formTypeMap = [
'com_content.article' => 'com_content',
'com_menus.item' => 'menu',
'com_categories.categorycom_content' => 'com_content.category',
];
$contentType = $formTypeMap[$formName] ?? 'com_content';
$contentType = ($formName === 'com_menus.item') ? 'menu' : 'com_content';
$ogData = $this->loadOgData($contentType, $id);
if ($ogData) {
@@ -114,9 +102,8 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
[$context, $article, $isNew] = array_values($event->getArguments());
$supportedContexts = [
'com_content.article' => 'com_content',
'com_menus.item' => 'menu',
'com_categories.categorycom_content' => 'com_content.category',
'com_content.article' => 'com_content',
'com_menus.item' => 'menu',
];
if (!isset($supportedContexts[$context])) {
@@ -156,9 +143,8 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
[$context, $article] = array_values($event->getArguments());
$supportedContexts = [
'com_content.article' => 'com_content',
'com_menus.item' => 'menu',
'com_categories.categorycom_content' => 'com_content.category',
'com_content.article' => 'com_content',
'com_menus.item' => 'menu',
];
if (!isset($supportedContexts[$context])) {
@@ -190,10 +176,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'og_title', 'og_description', 'og_image', 'og_type',
'seo_title', 'meta_description', 'robots', 'canonical_url',
]))
->select($db->quoteName(['og_title', 'og_description', 'og_image', 'og_type']))
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
->where($db->quoteName('content_id') . ' = ' . $contentId);
@@ -226,26 +209,15 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
$db->setQuery($query);
$existingId = $db->loadResult();
// Robots may come as array from multi-select, join with comma
$robots = $ogData['robots'] ?? '';
if (\is_array($robots)) {
$robots = implode(', ', array_filter($robots));
}
$record = (object) [
'content_type' => $contentType,
'content_id' => $contentId,
'og_title' => trim($ogData['og_title'] ?? ''),
'og_description' => trim($ogData['og_description'] ?? ''),
'og_image' => trim($ogData['og_image'] ?? ''),
'og_type' => trim($ogData['og_type'] ?? 'article'),
'seo_title' => trim($ogData['seo_title'] ?? ''),
'meta_description' => trim($ogData['meta_description'] ?? ''),
'robots' => trim($robots),
'canonical_url' => trim($ogData['canonical_url'] ?? ''),
'published' => 1,
'modified' => Factory::getDate()->toSql(),
'content_type' => $contentType,
'content_id' => $contentId,
'og_title' => trim($ogData['og_title'] ?? ''),
'og_description' => trim($ogData['og_description'] ?? ''),
'og_image' => trim($ogData['og_image'] ?? ''),
'og_type' => trim($ogData['og_type'] ?? 'article'),
'published' => 1,
'modified' => Factory::getDate()->toSql(),
];
if ($existingId) {
@@ -23,11 +23,3 @@ PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML_DESC="Remove HTML tags from the auto-generated description."
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH="Description Length"
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH_DESC="Maximum character length for the auto-generated og:description."
PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL="Telegram Channel"
PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL_DESC="Your Telegram channel handle (e.g. @mokoconsulting). Outputs a telegram:channel meta tag for Telegram link previews."
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
@@ -23,11 +23,3 @@ PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML_DESC="Remove HTML tags from the auto-generated description."
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH="Description Length"
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH_DESC="Maximum character length for the auto-generated og:description."
PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL="Telegram Channel"
PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL_DESC="Your Telegram channel handle (e.g. @mokoconsulting). Outputs a telegram:channel meta tag for Telegram link previews."
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
+1 -42
View File
@@ -8,7 +8,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoOpenGraph</name>
<version>01.00.00-dev</version>
<version>01.00.00</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -74,14 +74,6 @@
default=""
filter="string"
/>
<field
name="telegram_channel"
type="text"
label="PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL"
description="PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL_DESC"
default=""
filter="string"
/>
</fieldset>
<fieldset name="advanced" label="PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED">
<field
@@ -115,39 +107,6 @@
min="50"
max="300"
/>
<field
name="auto_resize"
type="radio"
label="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE"
description="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="jsonld_enabled"
type="radio"
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED"
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="jsonld_breadcrumbs"
type="radio"
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS"
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
@@ -17,8 +17,6 @@ use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\Plugin\System\MokoOG\Helper\ImageHelper;
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
final class MokoOG extends CMSPlugin implements SubscriberInterface
{
@@ -69,23 +67,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
// Try to load custom OG data from the database
$ogData = $this->loadOgData($option, $view, $id);
// For category views, also try category-level OG data as fallback
if ($option === 'com_content' && $view === 'category' && $id > 0) {
$catOg = $this->loadOgDataByType('com_content.category', $id);
if ($catOg) {
// Merge: category fills any gaps in the content-level data
foreach (['og_title', 'og_description', 'og_image', 'og_type', 'seo_title', 'meta_description', 'robots', 'canonical_url'] as $field) {
if (empty($ogData->$field) && !empty($catOg->$field)) {
$ogData->$field = $catOg->$field;
}
}
}
}
// --- SEO meta tags (set first, before OG) ---
$this->applySeoTags($doc, $ogData);
// Build tag values — custom overrides auto-generated
$title = $ogData->og_title ?: $doc->getTitle();
$description = $ogData->og_description ?: $this->buildDescription($doc);
@@ -106,11 +87,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
$doc->setMetaData('og:image', $imageUrl, 'property');
}
// og:locale from current language
$langTag = Factory::getLanguage()->getTag();
$ogLocale = str_replace('-', '_', $langTag);
$doc->setMetaData('og:locale', $ogLocale, 'property');
// Facebook App ID
$fbAppId = $this->params->get('fb_app_id', '');
@@ -133,74 +109,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
if ($twitterSite) {
$doc->setMetaData('twitter:site', $twitterSite);
}
// Telegram channel tag
$telegramChannel = $this->params->get('telegram_channel', '');
if ($telegramChannel) {
$doc->setMetaData('telegram:channel', $telegramChannel);
}
// JSON-LD structured data
if ($this->params->get('jsonld_enabled', 1)) {
$imageUrl = $image ? $this->resolveImageUrl($image) : '';
if ($option === 'com_content' && $view === 'article' && $id > 0) {
$schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl);
} else {
$schema = JsonLdBuilder::buildWebPage($title, $description);
}
if ($schema) {
$doc->addCustomTag(JsonLdBuilder::toScriptTag($schema));
}
if ($this->params->get('jsonld_breadcrumbs', 1)) {
$breadcrumbs = JsonLdBuilder::buildBreadcrumbs();
if ($breadcrumbs) {
$doc->addCustomTag(JsonLdBuilder::toScriptTag($breadcrumbs));
}
}
}
}
/**
* Apply SEO meta tags (title, description, robots, canonical) to the document.
*
* @param \Joomla\CMS\Document\HtmlDocument $doc The document
* @param object $ogData The loaded OG/SEO data
*
* @return void
*/
private function applySeoTags($doc, object $ogData): void
{
// Custom SEO title overrides the page <title>
if (!empty($ogData->seo_title)) {
$doc->setTitle($ogData->seo_title);
}
// Custom meta description
if (!empty($ogData->meta_description)) {
$doc->setDescription($ogData->meta_description);
}
// Robots directive
if (!empty($ogData->robots)) {
$doc->setMetaData('robots', $ogData->robots);
}
// Canonical URL
if (!empty($ogData->canonical_url)) {
// Remove any existing canonical link first
foreach ($doc->_links as $link => $attribs) {
if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') {
unset($doc->_links[$link]);
}
}
$doc->addHeadLink($ogData->canonical_url, 'canonical');
}
}
/**
@@ -215,14 +123,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
private function loadOgData(string $option, string $view, int $id): object
{
$empty = (object) [
'og_title' => '',
'og_description' => '',
'og_image' => '',
'og_type' => '',
'seo_title' => '',
'meta_description' => '',
'robots' => '',
'canonical_url' => '',
'og_title' => '',
'og_description' => '',
'og_image' => '',
'og_type' => '',
];
if (!$id) {
@@ -242,37 +146,11 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
->from($db->quoteName('#__mokoog_tags'))
->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(Factory::getLanguage()->getTag())
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
$db->setQuery($query, 0, 1);
return $db->loadObject() ?: $empty;
}
/**
* Load OG data by content type and ID.
*
* @param string $contentType Content type identifier
* @param int $contentId Content ID
*
* @return object|null
*/
private function loadOgDataByType(string $contentType, int $contentId): ?object
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
->where($db->quoteName('content_id') . ' = ' . $contentId)
->where($db->quoteName('published') . ' = 1');
$db->setQuery($query);
return $db->loadObject();
return $db->loadObject() ?: $empty;
}
/**
@@ -359,33 +237,13 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return $imagesData['image_intro'];
}
}
// Fallback: check the article's category for an image
if ($view === 'article') {
$catQuery = $db->getQuery(true)
->select($db->quoteName('cat.params'))
->from($db->quoteName('#__categories', 'cat'))
->join('INNER', $db->quoteName('#__content', 'a') . ' ON ' . $db->quoteName('a.catid') . ' = ' . $db->quoteName('cat.id'))
->where($db->quoteName('a.id') . ' = ' . (int) $id);
$db->setQuery($catQuery);
$catParams = $db->loadResult();
if ($catParams) {
$catData = json_decode($catParams, true);
if (!empty($catData['image'])) {
return $catData['image'];
}
}
}
}
return $this->params->get('default_image', '');
}
/**
* Resolve a relative image path to a full URL, resizing for OG if needed.
* Resolve a relative image path to a full URL.
*
* @param string $image Image path
*
@@ -397,11 +255,6 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
return $image;
}
// Auto-resize to OG recommended dimensions if enabled
if ($this->params->get('auto_resize', 1)) {
$image = ImageHelper::resize($image);
}
return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/');
}
}
@@ -1,160 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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;
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 {
$templateAbs = JPATH_ROOT . '/' . ltrim($templateImage, '/');
if (!is_file($templateAbs)) {
return '';
}
if (!$fontFile || !is_file($fontFile)) {
return '';
}
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
if (!is_dir($outputDir)) {
Folder::create($outputDir);
}
$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) {
return '';
}
$source = match ($imageInfo[2]) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($templateAbs),
IMAGETYPE_PNG => @imagecreatefrompng($templateAbs),
IMAGETYPE_WEBP => @imagecreatefromwebp($templateAbs),
default => false,
};
if (!$source) {
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);
$lines[2] = mb_substr($lines[2], 0, -3) . '...';
}
return implode("\n", $lines);
}
}
@@ -1,222 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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\File;
use Joomla\CMS\Filesystem\Folder;
class ImageHelper
{
/**
* Target width for OG images (Facebook recommended).
*/
private const TARGET_WIDTH = 1200;
/**
* Target height for OG images (Facebook recommended).
*/
private const TARGET_HEIGHT = 630;
/**
* JPEG quality for generated images.
*/
private const JPEG_QUALITY = 85;
/**
* Output directory relative to JPATH_ROOT.
*/
private const OUTPUT_DIR = 'images/mokoog/generated';
/**
* Resize an image to OG-optimized dimensions if needed.
*
* Returns the path to the resized image relative to JPATH_ROOT,
* or the original path if no resize was needed or possible.
*
* @param string $imagePath Image path relative to JPATH_ROOT
* @param int $targetWidth Target width (default 1200)
* @param int $targetHeight Target height (default 630)
* @param int $quality JPEG quality 1-100 (default 85)
*
* @return string Path to the output image (relative to JPATH_ROOT)
*/
public static function resize(
string $imagePath,
int $targetWidth = self::TARGET_WIDTH,
int $targetHeight = self::TARGET_HEIGHT,
int $quality = self::JPEG_QUALITY
): string {
// Resolve absolute path
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
if (!is_file($absPath)) {
return $imagePath;
}
$imageInfo = @getimagesize($absPath);
if (!$imageInfo) {
return $imagePath;
}
[$origWidth, $origHeight, $type] = $imageInfo;
// Skip if already at or below target size
if ($origWidth <= $targetWidth && $origHeight <= $targetHeight) {
return $imagePath;
}
// Ensure output directory exists
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
if (!is_dir($outputDir)) {
Folder::create($outputDir);
}
// Generate output filename based on source hash + dimensions
$hash = md5($imagePath . $targetWidth . $targetHeight);
$outputName = $hash . '.jpg';
$outputPath = $outputDir . '/' . $outputName;
$outputRel = self::OUTPUT_DIR . '/' . $outputName;
// Skip if already generated
if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) {
return $outputRel;
}
// Load source image
$source = self::loadImage($absPath, $type);
if (!$source) {
return $imagePath;
}
// Calculate crop dimensions (center crop to target aspect ratio)
$targetRatio = $targetWidth / $targetHeight;
$sourceRatio = $origWidth / $origHeight;
if ($sourceRatio > $targetRatio) {
// Source is wider — crop sides
$cropHeight = $origHeight;
$cropWidth = (int) round($origHeight * $targetRatio);
$cropX = (int) round(($origWidth - $cropWidth) / 2);
$cropY = 0;
} else {
// Source is taller — crop top/bottom
$cropWidth = $origWidth;
$cropHeight = (int) round($origWidth / $targetRatio);
$cropX = 0;
$cropY = (int) round(($origHeight - $cropHeight) / 2);
}
// Create output canvas and resample
$output = imagecreatetruecolor($targetWidth, $targetHeight);
imagecopyresampled(
$output,
$source,
0,
0,
$cropX,
$cropY,
$targetWidth,
$targetHeight,
$cropWidth,
$cropHeight
);
// Save as JPEG
imagejpeg($output, $outputPath, $quality);
imagedestroy($source);
imagedestroy($output);
return $outputRel;
}
/**
* Remove a generated image file.
*
* @param string $generatedPath Path relative to JPATH_ROOT
*
* @return void
*/
public static function cleanup(string $generatedPath): void
{
if (empty($generatedPath) || !str_starts_with($generatedPath, self::OUTPUT_DIR)) {
return;
}
$absPath = JPATH_ROOT . '/' . $generatedPath;
if (is_file($absPath)) {
File::delete($absPath);
}
}
/**
* Check if an image meets minimum OG size requirements.
*
* @param string $imagePath Image path relative to JPATH_ROOT
*
* @return array{valid: bool, width: int, height: int, message: string}
*/
public static function validate(string $imagePath): array
{
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
if (!is_file($absPath)) {
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'File not found'];
}
$imageInfo = @getimagesize($absPath);
if (!$imageInfo) {
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'Not a valid image'];
}
[$width, $height] = $imageInfo;
// Facebook minimum: 200x200, recommended: 1200x630
// WhatsApp minimum: 300x200
if ($width < 200 || $height < 200) {
return [
'valid' => false,
'width' => $width,
'height' => $height,
'message' => "Image too small ({$width}x{$height}). Minimum: 200x200px.",
];
}
return ['valid' => true, 'width' => $width, 'height' => $height, 'message' => 'OK'];
}
/**
* Load an image resource from a file.
*
* @param string $path Absolute file path
* @param int $type IMAGETYPE_* constant
*
* @return \GdImage|false
*/
private static function loadImage(string $path, int $type)
{
return match ($type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
IMAGETYPE_PNG => @imagecreatefrompng($path),
IMAGETYPE_GIF => @imagecreatefromgif($path),
IMAGETYPE_WEBP => @imagecreatefromwebp($path),
default => false,
};
}
}
@@ -1,168 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @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\Factory;
use Joomla\CMS\Uri\Uri;
class JsonLdBuilder
{
/**
* Build Article schema for a com_content article.
*
* @param int $articleId Article ID
* @param string $title Page title
* @param string $description Page description
* @param string $image Image URL (absolute)
*
* @return array|null
*/
public static function buildArticle(int $articleId, string $title, string $description, string $image): ?array
{
if ($articleId <= 0) {
return null;
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'a.created', 'a.modified', 'a.publish_up',
'u.name',
]))
->from($db->quoteName('#__content', 'a'))
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
->where($db->quoteName('a.id') . ' = ' . $articleId);
$db->setQuery($query);
$article = $db->loadObject();
if (!$article) {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $title,
'description' => $description,
'url' => Uri::getInstance()->toString(),
'datePublished' => $article->publish_up ?: $article->created,
'dateModified' => $article->modified ?: $article->created,
];
if (!empty($article->name)) {
$schema['author'] = [
'@type' => 'Person',
'name' => $article->name,
];
}
if (!empty($image)) {
$schema['image'] = $image;
}
return $schema;
}
/**
* Build WebPage schema for non-article pages.
*
* @param string $title Page title
* @param string $description Page description
*
* @return array
*/
public static function buildWebPage(string $title, string $description): array
{
return [
'@context' => 'https://schema.org',
'@type' => 'WebPage',
'name' => $title,
'description' => $description,
'url' => Uri::getInstance()->toString(),
];
}
/**
* Build BreadcrumbList schema from Joomla's pathway.
*
* @return array|null
*/
public static function buildBreadcrumbs(): ?array
{
$app = Factory::getApplication();
$pathway = $app->getPathway();
$items = $pathway->getPathway();
if (empty($items)) {
return null;
}
$listItems = [];
$position = 1;
foreach ($items as $item) {
$url = $item->link;
if ($url && !str_starts_with($url, 'http')) {
$url = rtrim(Uri::root(), '/') . '/' . ltrim($url, '/');
}
$listItems[] = [
'@type' => 'ListItem',
'position' => $position,
'name' => $item->name,
'item' => $url ?: Uri::getInstance()->toString(),
];
$position++;
}
return [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $listItems,
];
}
/**
* 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(),
];
}
/**
* Encode a schema array to a JSON-LD script tag string.
*
* @param array $schema Schema data
*
* @return string
*/
public static function toScriptTag(array $schema): string
{
$json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
return '<script type="application/ld+json">' . $json . '</script>';
}
}
@@ -1,5 +0,0 @@
; MokoOpenGraph - Web Services Plugin Language File
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_WEBSERVICES_MOKOOG="Web Services - MokoOpenGraph"
@@ -1,6 +0,0 @@
; MokoOpenGraph - Web Services Plugin System Language File
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_WEBSERVICES_MOKOOG="Web Services - MokoOpenGraph"
PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags."
@@ -1,5 +0,0 @@
; MokoOpenGraph - Web Services Plugin Language File
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_WEBSERVICES_MOKOOG="Web Services - MokoOpenGraph"
@@ -1,6 +0,0 @@
; MokoOpenGraph - Web Services Plugin System Language File
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_WEBSERVICES_MOKOOG="Web Services - MokoOpenGraph"
PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags."
@@ -1,19 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @subpackage plg_webservices_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
*
* Legacy entry point — not executed under DI container.
*/
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
class PlgWebservicesMokoog extends CMSPlugin
{
}
@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoOpenGraph
* @subpackage plg_webservices_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
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoOpenGraph</name>
<version>01.00.00-dev</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_WEBSERVICES_MOKOOG_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\WebServices\MokoOG</namespace>
<files>
<filename plugin="mokoog">mokoog.php</filename>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_webservices_mokoog.ini</language>
<language tag="en-GB">language/en-GB/plg_webservices_mokoog.sys.ini</language>
</languages>
</extension>
@@ -1,44 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @subpackage plg_webservices_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\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\WebServices\MokoOG\Extension\MokoOGWebServices;
return new class () implements ServiceProviderInterface {
/**
* Register the service provider.
*
* @param Container $container The DI container
*
* @return void
*/
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoOGWebServices(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('webservices', 'mokoog')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -1,81 +0,0 @@
<?php
/**
* @package MokoOpenGraph
* @subpackage plg_webservices_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\WebServices\MokoOG\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\ApiRouter;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\Router\Route;
final class MokoOGWebServices extends CMSPlugin implements SubscriberInterface
{
/**
* @var bool
*/
protected $autoloadLanguage = true;
/**
* Returns the events this plugin subscribes to.
*
* @return array<string, string>
*/
public static function getSubscribedEvents(): array
{
return [
'onBeforeApiRoute' => 'onBeforeApiRoute',
];
}
/**
* Register API routes for MokoOpenGraph.
*
* Endpoints:
* GET /api/index.php/v1/mokoog/tags - List all OG tags
* GET /api/index.php/v1/mokoog/tags/:id - Get single OG tag
* POST /api/index.php/v1/mokoog/tags - Create OG tag
* PATCH /api/index.php/v1/mokoog/tags/:id - Update OG tag
* DELETE /api/index.php/v1/mokoog/tags/:id - Delete OG tag
*
* @param Event $event The event object
*
* @return void
*/
public function onBeforeApiRoute(Event $event): void
{
[$router] = array_values($event->getArguments());
$defaults = [
'component' => 'com_mokoog',
'public' => false,
];
// CRUD routes for OG tags
$router->createCRUDRoutes(
'v1/mokoog/tags',
'tags',
$defaults
);
// GET by content type + content ID (lookup endpoint)
$router->addRoute(
new Route(
['GET'],
'v1/mokoog/lookup/:content_type/:content_id',
'tags.lookup',
['content_type' => '[a-z_.]+', 'content_id' => '(\d+)'],
$defaults
)
);
}
}
+4 -3
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>MokoOpenGraph</name>
<packagename>mokoog</packagename>
<version>01.00.00-dev</version>
<version>01.00.00</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -23,7 +23,6 @@
<file type="component" id="com_mokoog">com_mokoog.zip</file>
<file type="plugin" id="mokoog" group="system">plg_system_mokoog.zip</file>
<file type="plugin" id="mokoog" group="content">plg_content_mokoog.zip</file>
<file type="plugin" id="mokoog" group="webservices">plg_webservices_mokoog.zip</file>
</files>
<languages>
@@ -31,6 +30,8 @@
</languages>
<updateservers>
<server type="extension" name="MokoOpenGraph Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/raw/branch/main/updates.xml</server>
<server type="extension" name="MokoJoomOpenGraph Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/updates.xml</server>
</updateservers>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>
</extension>
-22
View File
@@ -73,28 +73,6 @@ class Pkg_MokoOGInstallerScript
$db->setQuery($query);
$db->execute();
// Enable the content plugin automatically
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('content'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokoog'));
$db->setQuery($query);
$db->execute();
// Enable the webservices plugin automatically
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('webservices'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokoog'));
$db->setQuery($query);
$db->execute();
}
}
}
-23
View File
@@ -1,23 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
VERSION: 01.00.00-dev
-->
<updates>
<update>
<name>MokoOpenGraph</name>
<description>MokoOpenGraph update</description>
<element>pkg_mokoog</element>
<type>package</type>
<version>01.00.00-dev</version>
<tags><tag>development</tag></tags>
<infourl title="MokoOpenGraph">https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/tag/development</infourl>
<downloads>
<downloadurl type="full" format="zip">https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases/download/development/pkg_pkg_mokoog-01.00.00-dev.zip</downloadurl>
</downloads>
<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" />
<maintainer>Moko Consulting</maintainer>
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
</update>
</updates>