Compare commits

..

5 Commits

160 changed files with 3770 additions and 7170 deletions
-62
View File
@@ -1,62 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto
# PHP files
*.php text eol=lf
# XML manifests
*.xml text eol=lf
# Language files
*.ini text eol=lf
# SQL files
*.sql text eol=lf
# Shell scripts
*.sh text eol=lf
# Markdown
*.md text eol=lf
# YAML
*.yml text eol=lf
*.yaml text eol=lf
# CSS/JS
*.css text eol=lf
*.js text eol=lf
# JSON
*.json text eol=lf
# Windows scripts
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Binary files
*.zip binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
# Export ignore (not included in archives)
.mokogitea/ export-ignore
.editorconfig export-ignore
.gitattributes export-ignore
.gitignore export-ignore
.gitmessage export-ignore
CLAUDE.md export-ignore
CONTRIBUTING.md export-ignore
CODE_OF_CONDUCT.md export-ignore
Makefile export-ignore
composer.json export-ignore
phpstan.neon export-ignore
-204
View File
@@ -1,204 +0,0 @@
# ============================================================
# Local task tracking (not version controlled)
# ============================================================
TODO.md
# ============================================================
# Environment and secrets
# ============================================================
.env
.env.local
.env.*.local
*.local.php
*.secret.php
configuration.php
configuration.*.php
configuration.local.php
conf/conf.php
conf/conf*.php
secrets/
*.secrets.*
# ============================================================
# Logs, dumps and databases
# ============================================================
*.db
*.db-journal
*.dump
*.log
*.pid
*.seed
# ============================================================
# OS / Editor / IDE cruft
# ============================================================
.DS_Store
Thumbs.db
desktop.ini
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
$RECYCLE.BIN/
System Volume Information/
*.lnk
Icon?
.idea/
.settings/
.claude/
.vscode/*
!.vscode/tasks.json
!.vscode/settings.json.example
!.vscode/extensions.json
*.code-workspace
*.sublime*
.project
.buildpath
.classpath
*.bak
*.swp
*.swo
*.tmp
*.old
*.orig
# ============================================================
# Dev scripts and scratch
# ============================================================
TODO.md
todo*
*ffs*
# ============================================================
# SFTP / sync tools
# ============================================================
sftp-config*.json
sftp-config.json.template
sftp-settings.json
# ============================================================
# Sublime SFTP / FTP sync
# ============================================================
*.sublime-project
*.sublime-workspace
*.sublime-settings
.libsass.json
*.ffs*
# ============================================================
# Replit / cloud IDE
# ============================================================
.replit
replit.md
# ============================================================
# Archives / release artifacts
# ============================================================
*.7z
*.rar
*.tar
*.tar.gz
*.tgz
*.zip
artifacts/
release/
releases/
# ============================================================
# Build outputs and site generators
# ============================================================
.mkdocs-build/
.cache/
.parcel-cache/
build/
dist/
out/
/site/
*.map
*.css.map
*.js.map
*.tsbuildinfo
# ============================================================
# CI / test artifacts
# ============================================================
.coverage
.coverage.*
coverage/
coverage.xml
htmlcov/
junit.xml
reports/
test-results/
tests/_output/
.github/local/
.github/workflows/*.log
# ============================================================
# Node / JavaScript
# ============================================================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
.yarn/
.npmrc
.eslintcache
package-lock.json
# ============================================================
# PHP / Composer tooling
# ============================================================
vendor/
!source/media/vendor/
composer.lock
*.phar
codeception.phar
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
.phpstan.cache
.phplint-cache
phpmd-cache/
.psalm/
.rector/
# ============================================================
# Python
# ============================================================
__pycache__/
*.py[cod]
*.pyc
*$py.class
*.so
.Python
.eggs/
*.egg
*.egg-info/
.installed.cfg
MANIFEST
develop-eggs/
downloads/
eggs/
parts/
sdist/
var/
wheels/
ENV/
env/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.pyright/
.tox/
.nox/
*.cover
*.coverage
hypothesis/
profile.ps1
.mcp.json
-67
View File
@@ -1,67 +0,0 @@
# MokoJoomOpenGraph
Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Per-article SEO with auto-generation fallback.
## Quick Reference
| Field | Value |
|---|---|
| **Package** | `pkg_mokoog` |
| **Language** | PHP 8.1+ |
| **Branch** | develop on `dev`, merge to `main` (protected) |
| **Wiki** | [MokoJoomOpenGraph Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/wiki) |
## Commands
```bash
make build # Build package ZIP
make lint # Run linters
make validate # Validate structure
make release # Full release pipeline
make clean # Clean build artifacts
composer install # Install PHP dependencies
```
## Architecture
Joomla **package** with three sub-extensions:
### com_mokoog (Component)
- Admin backend for viewing/managing all OG tag records
- Joomla 4/5 MVC: `Controller/DisplayController`, `Model/TagsModel`, `View/Tags/HtmlView`, `Table/TagTable`
- Namespace: `Joomla\Component\MokoOG\Administrator`
### plg_system_mokoog (System Plugin)
- Hooks `onBeforeCompileHead` to inject `<meta property="og:*">` and `<meta name="twitter:*">`
- Auto-generates tags from article title, description, images when no custom tags exist
- Supports articles (`com_content`), menu items, extensible content types
### plg_content_mokoog (Content Plugin)
- Hooks `onContentPrepareForm` to add OG fields tab to article/menu editors
- Hooks `onContentAfterSave`/`onContentAfterDelete` to persist/clean OG data
### Database Schema
Single table `#__mokoog_tags`:
- `content_type` + `content_id` = unique key for any content item
- `og_title`, `og_description`, `og_image`, `og_type` = custom OG overrides
- `published` flag for per-item enable/disable
## Rules
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js`
- **Attribution**: `Authored-by: Moko Consulting`
- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`)
- **Minification**: handled at build time (CI)
- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files
- **Standards**: [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
## Coding Standards
- PHP 8.1+ minimum
- Joomla 4/5 DI container pattern: `services/provider.php` → Extension class
- Legacy stub `.php` file required for plugin loader but empty
- `SubscriberInterface` for event subscription (not `on*` method naming)
- `bind() → check() → store()` for Table operations (not `save()`)
- Language file placement: site (no `folder`) vs admin (`folder="administrator"`)
- SPDX license headers on all PHP files
-251
View File
@@ -1,251 +0,0 @@
# 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
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
<identity>
<name>MokoOpenGraph</name>
<org>MokoConsulting</org>
<description>Open Graph, SEO meta tags, and social sharing image management for Joomla articles and menu items</description>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
<platform>joomla</platform>
<standards-version>05.00.00</standards-version>
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/moko-platform</standards-source>
</governance>
<build>
<language>PHP</language>
<package-type>joomla-extension</package-type>
<entry-point>src/</entry-point>
</build>
</moko-platform>
-66
View File
@@ -1,66 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokocli tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
# 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/mokocli
# 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
+210 -7
View File
@@ -1,10 +1,213 @@
# 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
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Maintenance
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# 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
jobs:
noop:
cascade:
name: Cascade main → branches
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip cascade]')
steps:
- run: echo "Cascade disabled — auto-release handles dev recreation"
- name: Discover target branches
id: branches
env:
GA_TOKEN: ${{ secrets.GA_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 ${GA_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.GA_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 ${GA_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 ${GA_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 ${GA_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 ${GA_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 ${GA_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
-191
View File
@@ -1,191 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
# PATH: /.gitea/workflows/ci-generic.yml
# VERSION: 01.00.00
# BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js)
name: "Generic: Project CI"
on:
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Lint & Validate ───────────────────────────────────────────────────
lint:
name: Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
echo "Toolchain: PHP=$HAS_PHP Node=$HAS_NODE"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
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
php -v
- name: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install PHP dependencies
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "composer.json" ]; then
composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
fi
- name: Install Node.js dependencies
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "package.json" ]; then
npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true
fi
- name: PHP syntax check
if: steps.detect.outputs.has_php == 'true'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
echo "::error file=${file}::PHP syntax error"
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -not -path "./node_modules/*" -print0)
echo "## PHP Lint" >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -eq 0 ]; then
echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
else
echo "${ERRORS} file(s) with syntax errors." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: TypeScript/JavaScript lint
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "node_modules/.bin/eslint" ]; then
npx eslint src/ --quiet 2>&1 || { echo "::error::ESLint errors found"; exit 1; }
echo "## ESLint" >> $GITHUB_STEP_SUMMARY
echo "All files passed ESLint." >> $GITHUB_STEP_SUMMARY
elif [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f "eslint.config.js" ]; then
echo "::warning::ESLint config found but eslint not installed"
else
echo "No ESLint configured — skipping"
fi
- name: TypeScript compile check
if: steps.detect.outputs.has_node == 'true'
run: |
if [ -f "tsconfig.json" ] && [ -f "node_modules/.bin/tsc" ]; then
npx tsc --noEmit 2>&1 || { echo "::error::TypeScript compilation errors"; exit 1; }
echo "## TypeScript" >> $GITHUB_STEP_SUMMARY
echo "TypeScript compilation passed." >> $GITHUB_STEP_SUMMARY
fi
- name: PHPStan static analysis
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "phpstan.neon" ] && [ -f "vendor/bin/phpstan" ]; then
vendor/bin/phpstan analyse --no-progress 2>&1 || { echo "::warning::PHPStan found issues"; }
fi
# ── Tests ─────────────────────────────────────────────────────────────
test:
name: Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect toolchain
id: detect
run: |
HAS_PHP=false
HAS_NODE=false
[ -f "composer.json" ] && HAS_PHP=true
[ -f "package.json" ] && HAS_NODE=true
echo "has_php=$HAS_PHP" >> "$GITHUB_OUTPUT"
echo "has_node=$HAS_NODE" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.detect.outputs.has_php == 'true'
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: Setup Node.js
if: steps.detect.outputs.has_node == 'true'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: |
[ -f "composer.json" ] && composer install --no-interaction --prefer-dist --quiet 2>/dev/null || true
[ -f "package.json" ] && { npm ci --quiet 2>/dev/null || npm install --quiet 2>/dev/null || true; }
- name: Run PHP tests
if: steps.detect.outputs.has_php == 'true'
run: |
if [ -f "vendor/bin/phpunit" ]; then
vendor/bin/phpunit --testdox 2>&1
echo "## PHPUnit" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
elif [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
echo "::warning::PHPUnit config found but phpunit not installed"
else
echo "No PHPUnit configured — skipping"
fi
- name: Run Node.js tests
if: steps.detect.outputs.has_node == 'true'
run: |
if jq -e '.scripts.test' package.json > /dev/null 2>&1; then
npm test 2>&1
echo "## Node.js Tests" >> $GITHUB_STEP_SUMMARY
echo "Tests passed." >> $GITHUB_STEP_SUMMARY
else
echo "No test script in package.json — skipping"
fi
- name: Build check
run: |
if [ -f "Makefile" ]; then
make build 2>&1 || echo "::warning::Build failed or not configured"
elif [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
npm run build 2>&1 || echo "::warning::Build failed"
fi
+17 -470
View File
@@ -35,32 +35,25 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Setup mokocli tools
- name: Clone MokoStandards
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_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: |
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
echo "mokocli already available on runner — skipping clone"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
fi
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -131,8 +124,8 @@ jobs:
echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
fi
# Check required tags: name, version, author
for TAG in name version author; do
# Check required tags: name, version, author, namespace (Joomla 5+)
for TAG in name version author namespace; do
if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
@@ -140,19 +133,6 @@ jobs:
echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
fi
done
# Namespace is required for components/plugins but not packages
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
if ! grep -q "<namespace" "$MANIFEST" 2>/dev/null; then
echo "Missing required tag: \`<namespace>\` (required for Joomla 5+ ${EXT_TYPE} extensions)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Found required tag: \`<namespace>\`" >> $GITHUB_STEP_SUMMARY
fi
else
echo "Package extension — \`<namespace>\` not required." >> $GITHUB_STEP_SUMMARY
fi
fi
if [ "${ERRORS}" -gt 0 ]; then
@@ -245,417 +225,14 @@ jobs:
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
fi
- name: Check config.xml and access.xml for components
run: |
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find all component manifests (XML with type="component")
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
if [ -z "$COMP_MANIFESTS" ]; then
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $COMP_MANIFESTS; do
COMP_DIR=$(dirname "$MANIFEST")
COMP_NAME=$(basename "$COMP_DIR")
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
# Check access.xml exists
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ACCESS_FILE" ]; then
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
for ACTION in core.admin core.manage; do
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
# Check config.xml exists
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$CONFIG_FILE" ]; then
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SQL schema validation
run: |
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find SQL files in source/htdocs
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$SQL_FILES" ]; then
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $SQL_FILES; do
# Basic syntax check: balanced parentheses, no empty files
SIZE=$(wc -c < "$FILE" | tr -d ' ')
if [ "$SIZE" -eq 0 ]; then
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
# Check for common SQL errors
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
done
# Check update SQL files follow version numbering pattern
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$UPDATE_DIR" ]; then
BAD_NAMES=0
for UFILE in "$UPDATE_DIR"/*.sql; do
[ ! -f "$UFILE" ] && continue
BASENAME=$(basename "$UFILE" .sql)
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
BAD_NAMES=$((BAD_NAMES + 1))
fi
done
if [ "$BAD_NAMES" -gt 0 ]; then
ERRORS=$((ERRORS + BAD_NAMES))
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Manifest file references check
run: |
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check <filename> references
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILENAMES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <folder> references
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FOLDERS; do
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <file> references in package manifests (ZIP files won't exist in source)
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Form XML validation
run: |
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$FORM_FILES" ]; then
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $FORM_FILES; do
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
# Check for valid Joomla form structure
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Deprecated Joomla API check
continue-on-error: true
run: |
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
# Joomla 3/4 deprecated patterns that break in Joomla 6
PATTERNS=(
'JFactory::'
'JText::'
'JHtml::'
'JRoute::'
'JUri::'
'JLog::'
'JTable::'
'JInput'
'CMSFactory::\$application'
'JApplicationCms'
)
for PATTERN in "${PATTERNS[@]}"; do
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
if [ -n "$HITS" ]; then
COUNT=$(echo "$HITS" | wc -l)
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + COUNT))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
else
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Template output escaping check
continue-on-error: true
run: |
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$TMPL_FILES" ]; then
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $TMPL_FILES; do
# Check for unescaped output: <?= $var ?> or echo $var without escape()
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$UNESCAPED" ]; then
HITS=$(echo "$UNESCAPED" | wc -l)
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
# Check for echo without escaping in template context
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$RAW_ECHO" ]; then
HITS=$(echo "$RAW_ECHO" | wc -l)
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
else
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Namespace consistency check
run: |
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find component/plugin manifests with <namespace> tags
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
if [ -z "$MANIFESTS" ]; then
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $MANIFESTS; do
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$NS_PATH" ] && continue
MANIFEST_DIR=$(dirname "$MANIFEST")
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
# Check PHP files have matching namespace
while IFS= read -r -d '' PHP_FILE; do
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
[ -z "$FILE_NS" ] && continue
# Namespace should start with the manifest namespace path
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SPDX license header check
continue-on-error: true
run: |
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
MISSING=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
TOTAL=0
while IFS= read -r -d '' FILE; do
TOTAL=$((TOTAL + 1))
if ! head -10 "$FILE" | grep -qi "SPDX"; then
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
MISSING=$((MISSING + 1))
fi
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$MISSING" -gt 0 ]; then
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
else
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Service provider check
run: |
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$PROVIDERS" ]; then
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
else
for FILE in $PROVIDERS; do
# Must return a ServiceProviderInterface
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
fi
# Must have return statement
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
fi
release-readiness:
name: Release Readiness Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.base_ref == 'main'
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Validate release readiness
run: |
@@ -761,19 +338,15 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP ${{ matrix.php }}
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -811,19 +384,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
php -v && composer --version
run: php -v && composer --version
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || secrets.GA_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
@@ -880,24 +448,3 @@ jobs:
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit $EXIT
pre-release:
name: Build RC Pre-Release
runs-on: ubuntu-latest
needs: [lint-and-validate, test]
if: github.event_name == 'pull_request'
steps:
- name: Trigger pre-release build
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
run: |
curl -s -X POST \
"${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" \
-H "Authorization: token ${GA_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
+4
View File
@@ -25,6 +25,10 @@
name: "Universal: Secret Scanning"
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
-73
View File
@@ -1,73 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.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
+1
View File
@@ -18,6 +18,7 @@ on:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
- "Cascade Main → Dev"
types:
- completed
+194 -534
View File
@@ -1,534 +1,194 @@
# 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
# ── Secret Scanning ──────────────────────────────────────────────────
gitleaks:
name: Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
- name: Scan PR commits for secrets
run: |
if gitleaks detect --source . --verbose \
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Potential secrets detected in PR commits"
exit 1
fi
# ── 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."
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# 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
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
alpha/*|beta/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Pre-release branches must target 'dev', not '${BASE}'"
fi
;;
rc/*)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Release candidate branches must target '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: |
PLATFORM=$(cat .mokogitea/.moko-platform 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: 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; }
@@ -1,71 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Validation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
# VERSION: 01.00.00
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
name: "Joomla: Metadata Validation"
on:
pull_request:
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
permissions:
contents: read
env:
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 }}
jobs:
validate-metadata:
name: "Validate Joomla Metadata"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Validate metadata against Joomla manifest
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
php ${MOKO_CLI}/joomla_metadata_validate.php \
--path . \
--token "${GITEA_TOKEN}" \
--org "${GITEA_ORG}" \
--repo "${GITEA_REPO}" \
--api-base "${GITEA_URL}/api/v1" \
--ci
if [ $? -ne 0 ]; then
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
exit 1
fi
+137 -180
View File
@@ -4,26 +4,15 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
push:
branches:
- dev
- 'fix/**'
- 'patch/**'
- 'hotfix/**'
- 'bugfix/**'
- 'chore/**'
- alpha
- beta
- rc
workflow_dispatch:
inputs:
stability:
@@ -46,74 +35,44 @@ env:
jobs:
build:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
name: "Build Pre-Release (${{ inputs.stability }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
token: ${{ secrets.GA_TOKEN }}
- name: Setup mokocli tools
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
run: |
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1
fi
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet
- name: Detect platform
id: platform
run: |
# Auto-detect and update platform if not set in manifest
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .gitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Resolve metadata and bump version
id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: |
# Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then
case "${{ github.ref_name }}" in
rc) STABILITY="release-candidate" ;;
alpha) STABILITY="alpha" ;;
beta) STABILITY="beta" ;;
*) STABILITY="development" ;;
esac
else
STABILITY="${{ inputs.stability || 'development' }}"
fi
CLI="/tmp/moko-platform-api/cli"
STABILITY="${{ inputs.stability }}"
case "$STABILITY" in
development) SUFFIX="-dev"; TAG="development" ;;
@@ -122,147 +81,145 @@ jobs:
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
esac
# Bump version via CLI: patch for dev/alpha/beta, minor for RC
case "$STABILITY" in
release-candidate) BUMP="minor" ;;
*) BUMP="patch" ;;
esac
# Bump patch version via CLI
CURRENT=$(php $CLI/version_read.php --path . 2>/dev/null)
[ -z "$CURRENT" ] && CURRENT="00.00.00"
php $CLI/version_bump.php --path .
VERSION=$(php $CLI/version_read.php --path . 2>/dev/null)
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true
# Set stability suffix and verify consistency
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
# Append suffix for output
if [ -n "$SUFFIX" ]; then
VERSION="${VERSION}${SUFFIX}"
fi
# Set platform-specific version with stability suffix
php $CLI/version_set_platform.php \
--path . --version "$VERSION" --stability "$STABILITY" --branch "${{ github.ref_name }}"
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
git commit -m "chore(version): bump ${CURRENT} → ${VERSION}${SUFFIX} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element via manifest_element.php
php ${MOKO_CLI}/manifest_element.php \
--path . --version "$VERSION" --stability "$STABILITY" \
--repo "${GITEA_REPO}" --github-output
# Read back element outputs
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
- name: Create release
id: release
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading)
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
else
NOTES="Release ${VERSION}"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "Release notes updated from CHANGELOG.md"
fi
- name: Build package and upload
- name: Build package
id: package
if: steps.eligibility.outputs.proceed == 'true'
run: |
CLI="/tmp/moko-platform-api/cli"
VERSION="${{ steps.meta.outputs.version }}"
SUFFIX="${{ steps.meta.outputs.suffix }}"
# Build ZIP + tar.gz via CLI (handles type prefix, excludes, multi-extension packages)
php $CLI/package_build.php \
--path . \
--version "${VERSION}${SUFFIX}" \
--output-dir build \
--github-output
- name: Create release and upload
run: |
CLI="/tmp/moko-platform-api/cli"
VERSION="${{ steps.meta.outputs.version }}"
SUFFIX="${{ steps.meta.outputs.suffix }}"
TAG="${{ steps.meta.outputs.tag }}"
STABILITY="${{ steps.meta.outputs.stability }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
EXT_ELEMENT="${{ steps.package.outputs.ext_element }}"
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
# updates.xml is generated dynamically by MokoGitea license server
# No need to build, commit, or sync updates.xml from workflows
SHA256="${{ steps.package.outputs.sha256_zip }}"
ZIP_PATH="${{ steps.package.outputs.zip_path }}"
TAR_PATH="${{ steps.package.outputs.tar_path }}"
- name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
--token "${TOKEN}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
# Create release
php $CLI/release_manage.php \
--action create \
--tag "$TAG" \
--name "${EXT_ELEMENT} ${VERSION}${SUFFIX} (${STABILITY})" \
--body "## ${VERSION}${SUFFIX} ($(date +%Y-%m-%d))\n**Channel:** ${STABILITY}\n**SHA-256:** \`${SHA256}\`" \
--target "${{ github.ref_name }}" \
--token "${{ secrets.GA_TOKEN }}" \
--api-base "$API_BASE"
# Upload assets
FILES="${ZIP_PATH}"
[ -f "$TAR_PATH" ] && FILES="${FILES},${TAR_PATH}"
php $CLI/release_manage.php \
--action upload \
--tag "$TAG" \
--files "$FILES" \
--token "${{ secrets.GA_TOKEN }}" \
--api-base "$API_BASE"
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
CLI="/tmp/moko-platform-api/cli"
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
# Map stability names
case "$STABILITY" in
release-candidate) CLI_STABILITY="rc" ;;
*) CLI_STABILITY="$STABILITY" ;;
esac
# Generate updates.xml with stability-suffixed versions
php $CLI/updates_xml_build.php \
--path . \
--version "$VERSION" \
--stability "$CLI_STABILITY" \
--sha "$SHA256" \
--gitea-url "${GITEA_URL}" \
--org "${GITEA_ORG}" \
--repo "${GITEA_REPO}"
# Commit and push
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
if: steps.platform.outputs.platform == 'joomla'
run: |
CURRENT_BRANCH="${{ github.ref_name }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml → ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
STABILITY="${{ steps.meta.outputs.stability }}"
# Map workflow stability names to CLI names
case "$STABILITY" in
release-candidate) CLI_STABILITY="rc" ;;
*) CLI_STABILITY="$STABILITY" ;;
esac
php /tmp/moko-platform-api/cli/release_cascade.php \
--stability "$CLI_STABILITY" \
--token "${{ secrets.GA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
-66
View File
@@ -1,66 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
name: "RC Revert"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
revert:
name: Rename rc/ back to dev/
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# 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
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+464
View File
@@ -0,0 +1,464 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Joomla
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/joomla/update-server.yml.template
# VERSION: 04.06.00
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
#
# Writes updates.xml with multiple <update> entries:
# - <tag>stable</tag> on push to main (from auto-release)
# - <tag>rc</tag> on push to rc/**
# - <tag>development</tag> on push to dev or dev/**
#
# Joomla filters by user's "Minimum Stability" setting.
name: "Joomla: Update Server"
on:
push:
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [closed]
branches:
- 'dev'
- 'dev/**'
- 'alpha/**'
- 'beta/**'
- 'rc/**'
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
stability:
description: 'Stability tag'
required: true
default: 'development'
type: choice
options:
- development
- alpha
- beta
- rc
- stable
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions:
contents: write
jobs:
update-xml:
name: Update updates.xml
runs-on: release
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GA_TOKEN }}
fetch-depth: 0
- name: Setup MokoStandards tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Generate updates.xml entry
id: update
run: |
BRANCH="${{ github.ref_name }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
# Auto-bump patch on all branches (dev, alpha, beta, rc)
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
if [ -n "$BUMPED" ]; then
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
git add -A
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
git push 2>/dev/null || true
fi
# Determine stability from branch or input
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
STABILITY="${{ inputs.stability }}"
elif [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
elif [[ "$BRANCH" == beta/* ]]; then
STABILITY="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
STABILITY="alpha"
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
STABILITY="development"
else
STABILITY="stable"
fi
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
# Parse manifest (portable — no grep -P)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "No Joomla manifest found — skipping"
exit 0
fi
# Extract fields using sed (works on all runners)
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
# Fallbacks
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
# Derive element if not in manifest: try XML filename, then repo name
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
case "$EXT_ELEMENT" in
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
esac
fi
# Use manifest version if README version is empty
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
CLIENT_TAG=""
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
FOLDER_TAG=""
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
PHP_TAG=""
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
# Version suffix for non-stable
DISPLAY_VERSION="$VERSION"
case "$STABILITY" in
development) DISPLAY_VERSION="${VERSION}-dev" ;;
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
esac
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
# Each stability level has its own release tag
case "$STABILITY" in
development) RELEASE_TAG="development" ;;
alpha) RELEASE_TAG="alpha" ;;
beta) RELEASE_TAG="beta" ;;
rc) RELEASE_TAG="release-candidate" ;;
*) RELEASE_TAG="v${MAJOR}" ;;
esac
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
# -- Build install packages (ZIP + tar.gz) --------------------
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ -d "$SOURCE_DIR" ]; then
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
cd "$SOURCE_DIR"
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
cd ..
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
--exclude='.ftpignore' --exclude='sftp-config*' \
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
# Ensure release exists on Gitea
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
# Create release
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/json" \
"${API_BASE}/releases" \
-d "$(python3 -c "import json; print(json.dumps({
'tag_name': '${RELEASE_TAG}',
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
'body': '${STABILITY} release',
'prerelease': True,
'target_commitish': 'main'
}))")" 2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
fi
if [ -n "$RELEASE_ID" ]; then
# Delete existing assets with same name before uploading
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
ASSET_ID=$(echo "$ASSETS" | python3 -c "
import sys,json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '${ASSET_FILE}':
print(a['id']); break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
fi
done
# Upload both formats
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${PACKAGE_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"/tmp/${TAR_NAME}" \
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
fi
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
else
SHA256=""
fi
# -- Build the new entry (canonical format matching release.yml) --
NEW_ENTRY=""
NEW_ENTRY="${NEW_ENTRY} <update>\n"
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
NEW_ENTRY="${NEW_ENTRY} </update>"
# -- Write new entry to temp file --------------------------------
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
# -- Merge into updates.xml ----------------------------------------
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
TARGETS=""
for entry in $CASCADE_MAP; do
key="${entry%%:*}"
vals="${entry#*:}"
if [ "$key" = "${STABILITY}" ]; then
TARGETS="$vals"
break
fi
done
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
echo "Cascade: ${STABILITY} → ${TARGETS}"
# Create updates.xml if missing
if [ ! -f "updates.xml" ]; then
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
printf '%s\n' "<updates>" >> updates.xml
printf '%s\n' "</updates>" >> updates.xml
fi
# Update existing blocks or create missing ones
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
python3 << 'PYEOF'
import re, os
targets = os.environ["PY_TARGETS"].split(",")
version = os.environ["PY_VERSION"]
date = os.environ["PY_DATE"]
with open("updates.xml") as f:
content = f.read()
with open("/tmp/new_entry.xml") as f:
new_entry_template = f.read()
for tag in targets:
tag = tag.strip()
# Build entry with this tag's name
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
# Try to find existing block (handles both single-line and multi-line <tags>)
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
match = re.search(block_pattern, content, re.DOTALL)
if match:
# Update in place — replace entire block
content = content.replace(match.group(1), new_entry.strip())
print(f" UPDATED: <tag>{tag}</tag> → {version}")
else:
# Create — insert before </updates>
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
print(f" CREATED: <tag>{tag}</tag> → {version}")
# Clean up excessive blank lines
content = re.sub(r"\n{3,}", "\n\n", content)
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git diff --cached --quiet || {
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
}
# -- Sync updates.xml to main (for non-main branches) ----------------------
- name: Sync updates.xml to main
if: github.ref_name != 'main'
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
GA_TOKEN="${{ secrets.GA_TOKEN }}"
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
CONTENT=$(base64 -w0 updates.xml)
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/contents/updates.xml" \
-d "$(python3 -c "import json; print(json.dumps({
'content': '${CONTENT}',
'sha': '${FILE_SHA}',
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
'branch': 'main'
}))")" > /dev/null 2>&1 \
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
else
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
fi
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
# -- Permission check: admin or maintain role required --------
ACTOR="${{ github.actor }}"
REPO="${{ github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
exit 0
;;
esac
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && exit 0
PORT="${DEV_PORT:-22}"
REMOTE="${DEV_PATH%/}"
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
if [ -n "$DEV_KEY" ]; then
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
fi
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
- name: Summary
if: always()
run: |
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
-130
View File
@@ -1,130 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow.Template
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
# PATH: /.mokogitea/workflows/version-set.yml
# VERSION: 01.00.00
# BRIEF: Set or reset the extension version across all version-bearing files
name: "Joomla: Set Version"
on:
workflow_dispatch:
inputs:
version:
description: "Version number (e.g. 01.00.00)"
required: true
type: string
branch:
description: "Branch to update (default: current)"
required: false
type: string
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
set-version:
name: Set Version to ${{ inputs.version }}
runs-on: ubuntu-latest
steps:
- name: Validate version format
run: |
VERSION="${{ inputs.version }}"
if ! echo "$VERSION" | grep -qP '^\d{2}\.\d{2}\.\d{2}$'; then
echo "::error::Invalid version format '${VERSION}' — expected XX.YY.ZZ (e.g. 01.00.00)"
exit 1
fi
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
- name: Update manifest version
run: |
MANIFEST=""
for XML_FILE in $(find . -maxdepth 3 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla extension manifest found — skipping manifest update"
else
OLD_VER=$(grep -oP '<version>\K[^<]+' "$MANIFEST" | head -1)
sed -i "s|<version>${OLD_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
echo "Manifest: ${OLD_VER} → ${VERSION} (${MANIFEST})"
fi
- name: Update README.md version
run: |
if [ -f "README.md" ]; then
if grep -qP '^\s*VERSION:\s*\d' README.md; then
sed -i -E "s/(VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" README.md
echo "README.md version updated to ${VERSION}"
else
echo "::warning::No VERSION line found in README.md — skipping"
fi
fi
- name: Update CHANGELOG.md
run: |
if [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
# Check if this version already has an entry
if grep -q "^\#\# \[${VERSION}\]" CHANGELOG.md; then
echo "CHANGELOG.md already has entry for ${VERSION} — skipping"
else
# Insert new version entry after [Unreleased] or at the top after header
if grep -q '^\#\# \[Unreleased\]' CHANGELOG.md; then
sed -i "/^\#\# \[Unreleased\]/a\\\\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
else
sed -i "/^\# Changelog/a\\\\n## [Unreleased]\n\n## [${VERSION}] --- ${DATE}" CHANGELOG.md
fi
echo "CHANGELOG.md: added entry for ${VERSION}"
fi
else
echo "::warning::No CHANGELOG.md found — skipping"
fi
- name: Update FILE INFORMATION blocks
run: |
# Update VERSION in file header blocks (# VERSION: XX.YY.ZZ)
find . -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" -o -name "*.php" -o -name "*.md" \) \
-not -path "./.git/*" -not -path "./vendor/*" -print0 2>/dev/null | \
while IFS= read -r -d '' FILE; do
if head -20 "$FILE" | grep -qP '^\s*#?\s*VERSION:\s*\d{2}\.\d{2}\.\d{2}'; then
sed -i -E "s/(#?\s*VERSION:\s*)[0-9]{2}\.[0-9]{2}\.[0-9]{2}/\1${VERSION}/" "$FILE"
echo "Updated FILE INFORMATION VERSION in ${FILE}"
fi
done
- name: Commit and push
run: |
git config user.name "Moko Consulting [bot]"
git config user.email "hello@mokoconsulting.tech"
git add -A
if git diff --cached --quiet; then
echo "No version changes detected — nothing to commit"
else
git commit -m "chore: set version to ${VERSION} [skip bump]
Authored-by: Moko Consulting"
git push
echo "### Version Set" >> $GITHUB_STEP_SUMMARY
echo "Version updated to \`${VERSION}\` on branch \`${GITHUB_REF_NAME}\`" >> $GITHUB_STEP_SUMMARY
fi
@@ -1,73 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
# VERSION: 01.01.00
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
name: "Universal: Workflow Sync Trigger"
on:
pull_request:
types: [closed]
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
steps:
- name: Determine platform from repo name
id: platform
run: |
REPO="${{ github.event.repository.name }}"
case "$REPO" in
Template-Joomla) PLATFORM="joomla" ;;
Template-Dolibarr) PLATFORM="dolibarr" ;;
Template-Go) PLATFORM="go" ;;
Template-MCP) PLATFORM="mcp" ;;
Template-Generic) PLATFORM="" ;;
*) PLATFORM="" ;;
esac
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}"
- name: Clone mokocli
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install dependencies
run: |
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- name: Run workflow sync
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
ARGS="--token ${MOKOGITEA_TOKEN}"
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
ARGS="${ARGS} --phase repos"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ -n "$PLATFORM" ]; then
ARGS="${ARGS} --platform-filter ${PLATFORM}"
fi
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
+12 -57
View File
@@ -1,66 +1,21 @@
# Changelog
## [Unreleased]
<!-- VERSION: 01.00.00 -->
## [01.04.00] --- 2026-06-23
<!-- VERSION: 01.04.00 -->
All notable changes to MokoSuiteOpenGraph will be documented in this file.
All notable changes to MokoOpenGraph will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [01.04.00] --- 2026-06-23
### Security
- Fix JSON-LD XSS vulnerability via `</script>` injection in content data (#34)
- Add ACL permission checks to Batch and ImportExport controllers (#37)
- Add CSV import file type, MIME type, size, and content_type validation (#35)
- Fix multilingual data corruption in content plugin load/save (#41)
## [Unreleased]
### Added
- Fediverse/Mastodon `fediverse:creator` meta tag — first extension on any CMS to support this (#57)
- Live character count indicators on OG title, OG description, SEO title, meta description fields with color-coded warnings (#58)
- LinkedIn social preview card in article/menu editor alongside Facebook and Twitter/X previews (#61)
- `og:video` meta tag support with per-article video URL field, auto-detect MIME type for YouTube/Vimeo/direct files (#59)
- Pinterest rich pin tags: `article:tag` from Joomla content tags, `product:availability` from MokoSuiteShop stock (#60)
- Site-wide default OG title and description plugin parameters
- Discord embed color via `theme-color` meta tag (color picker in plugin config)
- LinkedIn article tags: `article:published_time`, `article:modified_time`, `article:author`
- `og:image:width` and `og:image:height` for faster social preview rendering
- `onMokoOGAfterRender` event for third-party plugin extensibility
- Joomla Web Services API for OG tags — full CRUD at `/api/v1/mokoog/tags` (#27)
- Live social preview in article/menu editors (Facebook and Twitter/X card mockups) (#3)
- CSV import/export for bulk OG tag management (#12)
- OG image text overlay generator (#7)
- Multilingual OG tag support with per-language records (#11)
- JSON-LD structured data: Article, Product, WebPage, BreadcrumbList schemas (#6)
- Social platform debugger quick links (Facebook, LinkedIn, Google) (#9)
- MokoSuiteShop product OG tag support with pricing meta and JSON-LD Product schema (#53)
- WhatsApp and Telegram link preview optimization (#10)
- Category-level OG tag support (#4)
- Batch OG tag generation for existing articles (#1)
- Auto-resize OG images to 1200x630px with center crop (#2)
- SEO meta tag management: title, description, robots, canonical URL (#8)
- Per-article and per-menu-item OG fields in the editor
- Auto-generation of OG tags from article content, title, and images
- Initial package structure with component, system plugin, and content plugin
- Open Graph meta tag injection via system plugin (`onBeforeCompileHead`)
- Twitter/X Card meta tag support (Summary and Summary with Large Image)
- Per-article OG fields in the article editor
- Per-menu-item OG fields in the menu item editor
- Auto-generation of OG tags from article title, description, and images
- Default fallback image configuration
- Admin tag manager component with filtering, search, and pagination
- Facebook App ID and Telegram channel support
- Database table `#__mokoog_tags` with multilingual unique key
### Changed
- Consolidated article DB queries into single cached lookup — 5 queries reduced to 1 (#38)
- Dynamic `og:image:width`/`og:image:height` from actual image dimensions instead of hardcoded (#39)
- Replace GD `@` error suppression with `Log::add()` warnings (#49)
- TagTable::check() validates og_type, field lengths, canonical_url, robots directives (#43)
- CSV import/export now includes language column for multilingual support (#52)
- Batch process limit capped at 200 per request (#42)
- Canonical URL replacement uses public `getHeadData()`/`setHeadData()` API (#39)
- Language-aware queries on `loadOgDataByType()` and `loadOgDataByMenu()` (#47)
### Removed
- Removed dead ContentType adapters (K2, VirtueMart, HikaShop) — not targeting these platforms (#36)
- Removed `<updateservers>` from package manifest — managed externally (#44)
- Removed deploy-manual.yml workflow
- Admin tag manager component for viewing all OG records
- Facebook App ID support
- Database table `#__mokoog_tags` for storing custom OG data
+78
View File
@@ -0,0 +1,78 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with this repository.
## Project Overview
**MokoOpenGraph** -- Open Graph, Twitter Card, and social sharing meta tag management for Joomla
| Field | Value |
|---|---|
| **Platform** | joomla |
| **Language** | PHP |
| **Default branch** | main |
| **License** | GPL-3.0-or-later |
| **Wiki** | [MokoOpenGraph Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/wiki) |
| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) |
## Common Commands
```bash
make build # Build the project
make lint # Run linters
make validate # Validate structure
make release # Full release pipeline
make minify # Minify CSS/JS assets
make clean # Clean build artifacts
```
```bash
composer install # Install PHP dependencies
```
## Architecture
This is a Joomla **package** extension (`pkg_mokoog`) containing three sub-extensions:
### com_mokoog (Component)
- Admin backend for viewing and managing all OG tag records
- Joomla 4/5 MVC: `Controller/DisplayController`, `Model/TagsModel`, `View/Tags/HtmlView`, `Table/TagTable`
- Namespace: `Joomla\Component\MokoOG\Administrator`
- Database table: `#__mokoog_tags` — stores custom OG data per content item
### plg_system_mokoog (System Plugin)
- Hooks `onBeforeCompileHead` to inject `<meta property="og:*">` and `<meta name="twitter:*">` tags
- Auto-generates tags from article title, description, and images when no custom tags exist
- Supports articles (`com_content`), menu items, and extensible content types
- Namespace: `Joomla\Plugin\System\MokoOG`
### plg_content_mokoog (Content Plugin)
- Hooks `onContentPrepareForm` to add OG fields tab to article and menu item editors
- Hooks `onContentAfterSave` / `onContentAfterDelete` to persist/clean OG data
- Namespace: `Joomla\Plugin\Content\MokoOG`
### Database Schema
Single table `#__mokoog_tags`:
- `content_type` + `content_id` = unique key identifying any content item
- `og_title`, `og_description`, `og_image`, `og_type` = custom OG overrides
- `published` flag for enabling/disabling per-item
## Rules
- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js`
- **Attribution**: use `Authored-by: Moko Consulting` in commits
- **Branch strategy**: develop on `dev`, merge to `main` for release
- **Minification**: handled at build time (CI)
- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files
- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home)
## Coding Standards
- PHP 8.1+ minimum
- Joomla 4/5 DI container pattern: `services/provider.php` → Extension class
- Legacy stub `.php` file required for plugin loader but empty
- `SubscriberInterface` for event subscription (not `on*` method naming)
- `bind() → check() → store()` for Table operations (not `save()`)
- Language file placement: site (no `folder`) vs admin (`folder="administrator"`)
- SPDX license headers on all PHP files
-28
View File
@@ -1,28 +0,0 @@
# Code of Conduct
## Our Pledge
We pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to a positive environment:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
Examples of unacceptable behavior:
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at hello@mokoconsulting.tech. All complaints will be reviewed and investigated.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
-34
View File
@@ -1,34 +0,0 @@
# Contributing to MokoJoomOpenGraph
Thank you for your interest in contributing to MokoJoomOpenGraph.
## Getting Started
1. Fork the repository on Gitea
2. Create a feature branch from `dev` (`feature/your-feature`)
3. Make your changes following the coding standards below
4. Submit a pull request targeting `dev`
## Branch Strategy
- `main` — stable releases only
- `dev` — active development
- `feature/*` — new features (target `dev`)
- `fix/*` — bug fixes (target `dev`)
- `hotfix/*` — urgent fixes (target `dev` or `main`)
## Coding Standards
- PHP 8.1+ required
- Follow Joomla coding standards
- SPDX license headers on all PHP files
- Use `SubscriberInterface` for event subscription
- Use `bind() -> check() -> store()` for Table operations
## Reporting Issues
Report bugs and feature requests via [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/issues).
## License
By contributing, you agree that your contributions will be licensed under GPL-3.0-or-later.
-318
View File
@@ -1,318 +0,0 @@
# MokoSuiteOpenGraph — Code Assessment Issues
Generated: 2026-06-06
Updated: 2026-06-21
Reviewed: Full codebase (all PHP, SQL, XML, JS, CSS, templates)
---
## Status Legend
- FIXED — Verified resolved in codebase
- OPEN — Still present, needs work
- WONTFIX — Intentional or acceptable as-is
---
## Bugs
### BUG-01: Batch generation offset pagination skips articles — FIXED
**Severity:** High
**File:** `source/packages/com_mokoog/src/Controller/BatchController.php:89`
The `process()` method now correctly uses `$db->setQuery($query, 0, $limit)` with a comment explaining that processed articles are automatically excluded by the LEFT JOIN filter.
---
### BUG-02: License key session flag set before check completes — FIXED
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:543`
Session flag is now set after the DB query succeeds, inside the try block but after query setup. If the query throws, the catch block runs without the flag being set.
---
### BUG-03: Hardcoded og:image dimensions are often wrong — FIXED
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:129-134`
Now uses `$this->getImageDimensions($image)` which calls `getimagesize()` to detect actual dimensions. Dimension meta tags only emitted when dimensions are successfully detected.
---
### BUG-04: `strlen()` vs `mb_strlen()` inconsistency in truncation — FIXED
**Severity:** Low
**Files:** MokoOG.php, BatchController.php, HikaShopAdapter.php, K2Adapter.php
All instances now consistently use `mb_strlen()` for length checks with `mb_substr()` for truncation.
---
### BUG-05: `ImageGenerator::wrapText()` can produce broken output — FIXED
**Severity:** Low
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php:156`
Now checks `mb_strlen($lines[2]) > 3` before truncating. Short lines get `'...'` appended instead.
---
## Potential Issues
### ISSUE-01: ContentType adapters exist but are never wired up — OPEN
**Severity:** High (wasted code)
**Files:**
- `source/packages/com_mokoog/src/ContentType/ContentTypeInterface.php`
- `source/packages/com_mokoog/src/ContentType/HikaShopAdapter.php`
- `source/packages/com_mokoog/src/ContentType/K2Adapter.php`
- `source/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php`
The system plugin (`MokoOG.php`) still never references or loads these adapters. The `findImage()` and `loadOgData()` methods only handle `com_content`. Third-party content types get no auto-generated OG tags.
**Action:** Wire adapters into the system plugin's `onBeforeCompileHead` flow, or remove them if not planned for v1.
---
### ISSUE-02: `applySeoTags()` accesses internal `$doc->_links` property — OPEN
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:257-259`
Still directly accessing `$doc->_links` (protected/internal property). Fragile across Joomla versions.
**Fix:** Use `$doc->getHeadData()` to read links and `$doc->addHeadLink()` with proper clearing logic.
---
### ISSUE-03: No input sanitization on OG values before output — OPEN
**Severity:** Medium
**File:** `source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php`
No `htmlspecialchars()` or `InputFilter` found in the content plugin's save path. While Joomla's `setMetaData()` escapes on output, defense-in-depth recommends sanitizing on input.
**Fix:** Apply `htmlspecialchars()` or Joomla's `InputFilter` when saving OG data.
---
### ISSUE-04: `loadOgDataByType()` and `loadOgDataByMenu()` ignore language — OPEN
**Severity:** Medium
**Files:**
- `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:324-337` (`loadOgDataByType`)
- `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:346-359` (`loadOgDataByMenu`)
These methods still have no language filter. On multilingual sites, category fallback or menu OG data could come from any language. The unique key is now `(content_type, content_id, language)` but these queries don't filter by language, so `loadObject()` returns an arbitrary match.
**Fix:** Add the same language filter pattern used in `loadOgData()`.
---
### ISSUE-05: VirtueMart adapter interpolates language into table name — OPEN (low risk)
**Severity:** Low (defense-in-depth)
**File:** `source/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php:34,47`
Language tag is interpolated into the table name. While `quoteName()` wraps the result, the language tag itself is not validated against an allowlist.
**Fix:** Validate tag format with a regex before interpolation.
---
### ISSUE-06: No admin list controller for publish/delete operations — OPEN
**Severity:** Medium
**File:** `source/packages/com_mokoog/src/Controller/`
No `TagsController extends AdminController` exists. The admin list view toolbar buttons for delete/publish/unpublish will produce task routing errors.
**Fix:** Add a `TagsController extends AdminController` with proper CSRF and ACL checks.
---
### ISSUE-07: CSV import/export does not handle `language` column — OPEN
**Severity:** Low
**File:** `source/packages/com_mokoog/src/Controller/ImportExportController.php`
No reference to `language` found in the controller. Export omits the column, import creates records with default `*` language. Multilingual sites cannot bulk import/export language-specific OG data.
**Fix:** Add `language` as a column in export, and parse it on import with a fallback to `*`.
---
### ISSUE-08: No ACL check in content plugin form injection — WONTFIX
**Severity:** Low
**File:** `source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php:49`
Any user who can edit an article can modify OG tags. This is acceptable behavior for most sites — if you can edit the article, you should be able to control its social sharing appearance.
---
## New Issues (Found 2026-06-21)
### ISSUE-09: ImageGenerator uses @ error suppression on GD functions
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
All GD library calls use the `@` suppression operator, making debugging difficult. If the GD extension is missing or a font file is not found, failures are completely silent.
**Fix:** Replace `@` suppression with proper error checking and logging via `Log::add()`.
---
### ISSUE-10: No TTF font file bundled or documented
**Severity:** Medium
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
The image generator requires a TTF font file for text overlay, but no font is included in the package and no fallback or documentation exists for configuring the font path.
**Fix:** Bundle a permissively-licensed font (e.g., Open Sans, Noto Sans) or document the required configuration.
---
### ISSUE-11: ImageGenerator cache grows unbounded
**Severity:** Low
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
Generated images in `images/mokoog/generated/` are never cleaned up. On sites with many articles, this directory grows indefinitely.
**Fix:** Add a cleanup CLI command or admin button (see FEAT-07), or implement LRU/TTL-based cache eviction.
---
### ISSUE-12: JSON-LD missing common schema types
**Severity:** Low
**File:** `source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php`
Only 4 schema types are implemented (Article, WebPage, BreadcrumbList, Organization). Missing: NewsArticle, BlogPosting, Product, VideoObject, Event — some of which correspond to existing `og_type` dropdown values.
**Fix:** Add at least NewsArticle and BlogPosting as Article subtypes.
---
### ISSUE-13: No API input validation beyond field whitelisting
**Severity:** Low
**Files:**
- `source/packages/com_mokoog/api/src/Controller/TagsController.php`
- `source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php`
The REST API exposes full CRUD but has no validation for field content (e.g., max lengths, valid URLs for og_image/canonical_url, valid og_type values).
**Fix:** Add validation rules matching the form XML constraints.
---
## Feature Expansion Opportunities
### FEAT-01: Wire up ContentType adapter system — NOT IMPLEMENTED
Connect the existing `ContentTypeInterface` adapters to the system plugin so HikaShop products, K2 items, and VirtueMart products automatically get OG tags. Blocked by ISSUE-01.
---
### FEAT-02: Admin edit view for individual OG tag records — NOT IMPLEMENTED
A `TagModel` and `tag.xml` form exist but there's no edit template (`tmpl/tag/`) or `TagController`. Users can only manage OG tags through article/menu editors.
---
### FEAT-03: Publish/unpublish toggle in admin list — NOT IMPLEMENTED
Blocked by ISSUE-06 (no TagsController). The list view shows published status as text but has no clickable toggle.
---
### FEAT-04: Actual image dimension detection for og:image meta — FIXED
Implemented via `getImageDimensions()` method using `getimagesize()`. See BUG-03.
---
### FEAT-05: Duplicate OG tag detection — NOT IMPLEMENTED
No detection for conflicting OG meta tags from other extensions.
---
### FEAT-06: Support og:video and og:audio URLs — NOT IMPLEMENTED
No `og_video` or `og_audio` columns, form fields, or rendering logic found anywhere in the codebase.
---
### FEAT-07: Generated image cache cleanup — NOT IMPLEMENTED
No CLI command or admin purge button. See ISSUE-11.
---
### FEAT-08: Sitemap integration — NOT IMPLEMENTED
No sitemap generation or integration exists.
---
### FEAT-09: Social share preview in admin list — NOT IMPLEMENTED
No thumbnails or inline validation in the admin list view. Live preview only exists in the article/menu editor (via plg_content_mokoog).
---
### FEAT-10: Bulk OG tag editing — NOT IMPLEMENTED
No batch edit modal for selecting multiple items and changing common fields.
---
## Security Fixes (from CHANGELOG [Unreleased])
All 4 claimed security fixes have been **verified as implemented**:
| Fix | Status | Evidence |
|-----|--------|----------|
| JSON-LD XSS (#34) | IMPLEMENTED | `</` escaping in `JsonLdBuilder::toScriptTag()` |
| ACL on Batch/ImportExport (#37) | IMPLEMENTED | `authorise()` checks on all controller methods |
| CSV import validation (#35) | IMPLEMENTED | File type, MIME, size (2MB), content_type regex |
| Multilingual data corruption (#41) | IMPLEMENTED | Language-aware load/save in content plugin |
Additional security review found **no vulnerabilities** for: SQL injection, CSRF, file upload, path traversal, code injection, or XSS in output.
---
## Summary
| Category | Total | Fixed | Open | Won't Fix |
|----------|-------|-------|------|-----------|
| Bugs | 5 | 5 | 0 | 0 |
| Issues | 13 | 0 | 12 | 1 |
| Features | 10 | 1 | 9 | 0 |
| Security | 4 | 4 | 0 | 0 |
### Priority for v1.0.0 Release
**Must fix:**
- ISSUE-06: TagsController for admin list operations (publish/delete broken)
- ISSUE-04: Language filter on loadOgDataByType/loadOgDataByMenu (data integrity on multilingual sites)
**Should fix:**
- ISSUE-02: Replace `$doc->_links` access (Joomla version fragility)
- ISSUE-03: Input sanitization on save (defense-in-depth)
- ISSUE-09: GD error suppression (debuggability)
- ISSUE-10: Bundle or document TTF font requirement
**Nice to have for v1.0.0:**
- FEAT-02: Admin edit view
- FEAT-03: Publish/unpublish toggle
- ISSUE-07: Language column in CSV import/export
+203
View File
@@ -0,0 +1,203 @@
# Makefile for Joomla Extensions
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# MokoOpenGraph — Open Graph & social sharing meta tag management
# ==============================================================================
# CONFIGURATION - Customize these for your extension
# ==============================================================================
# Extension Configuration
EXTENSION_NAME := mokoog
EXTENSION_TYPE := package
# Options: module, plugin, component, package, template
EXTENSION_VERSION := 1.0.0
# Module Configuration (for modules only)
MODULE_TYPE := site
# Options: site, admin
# Plugin Configuration (for plugins only)
PLUGIN_GROUP := system
# Options: system, content, user, authentication, etc.
# Directories
SRC_DIR := src
BUILD_DIR := build
DIST_DIR := dist
DOCS_DIR := docs
# Joomla Installation (for local testing - customize paths)
JOOMLA_ROOT := /var/www/html/joomla
JOOMLA_VERSION := 4
# Tools
PHP := php
COMPOSER := composer
NPM := npm
PHPCS := vendor/bin/phpcs
PHPCBF := vendor/bin/phpcbf
PHPUNIT := vendor/bin/phpunit
ZIP := zip
# Coding Standards
PHPCS_STANDARD := Joomla
# Colors for output
COLOR_RESET := \033[0m
COLOR_GREEN := \033[32m
COLOR_YELLOW := \033[33m
COLOR_BLUE := \033[34m
COLOR_RED := \033[31m
# ==============================================================================
# TARGETS
# ==============================================================================
.PHONY: help
help: ## Show this help message
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)"
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)"
@echo ""
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
.PHONY: install-deps
install-deps: ## Install all dependencies (Composer + npm)
@echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) install; \
echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \
fi
.PHONY: lint
lint: ## Run PHP linter (syntax check)
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
@find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \
-exec $(PHP) -l {} \; | grep -v "No syntax errors" || true
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
.PHONY: phpcs
phpcs: ## Run PHP CodeSniffer (Joomla standards)
@echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)"
@if [ -f "$(PHPCS)" ]; then \
$(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \
else \
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \
fi
.PHONY: validate
validate: lint phpcs ## Run all validation checks
@echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)"
.PHONY: clean
clean: ## Clean build artifacts
@echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)"
@rm -rf $(BUILD_DIR) $(DIST_DIR)
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
.PHONY: minify
minify: ## Minify CSS/JS assets
@echo "Minifying assets..."
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
elif [ -f "scripts/minify.js" ]; then \
node scripts/minify.js; \
else \
echo "No minify script found"; \
fi
.PHONY: build
build: clean validate minify ## Build extension package
@echo "$(COLOR_BLUE)Building Joomla extension package...$(COLOR_RESET)"
@mkdir -p $(DIST_DIR) $(BUILD_DIR)
# Determine package prefix based on extension type
@case "$(EXTENSION_TYPE)" in \
module) \
PACKAGE_PREFIX="mod_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
plugin) \
PACKAGE_PREFIX="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
component) \
PACKAGE_PREFIX="com_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
package) \
PACKAGE_PREFIX="pkg_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
template) \
PACKAGE_PREFIX="tpl_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
*) \
echo "$(COLOR_RED)✗ Unknown extension type: $(EXTENSION_TYPE)$(COLOR_RESET)"; \
exit 1; \
;; \
esac; \
\
mkdir -p "$$BUILD_TARGET"; \
\
echo "Building $$PACKAGE_PREFIX..."; \
\
rsync -av --progress \
--exclude='$(BUILD_DIR)' \
--exclude='$(DIST_DIR)' \
--exclude='.git*' \
--exclude='vendor/' \
--exclude='node_modules/' \
--exclude='tests/' \
--exclude='Makefile' \
--exclude='composer.json' \
--exclude='composer.lock' \
--exclude='package.json' \
--exclude='package-lock.json' \
--exclude='phpunit.xml' \
--exclude='*.md' \
--exclude='.editorconfig' \
. "$$BUILD_TARGET/"; \
\
cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip" "$${PACKAGE_PREFIX}"; \
\
echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
.PHONY: package
package: build ## Alias for build
@echo "$(COLOR_GREEN)✓ Package ready for distribution$(COLOR_RESET)"
.PHONY: release
release: validate build ## Create a release (validate + build)
@echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)"
.PHONY: version
version: ## Display version information
@echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)"
@echo " Name: $(EXTENSION_NAME)"
@echo " Type: $(EXTENSION_TYPE)"
@echo " Version: $(EXTENSION_VERSION)"
.PHONY: security-check
security-check: ## Run security checks on dependencies
@echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \
fi
.PHONY: all
all: install-deps validate build ## Run complete build pipeline
@echo "$(COLOR_GREEN)✓ Complete build pipeline finished$(COLOR_RESET)"
# Default target
.DEFAULT_GOAL := help
+15 -44
View File
@@ -1,69 +1,40 @@
# MokoSuiteOpenGraph
# MokoOpenGraph
<!-- VERSION: 01.04.00 -->
<!-- VERSION: 01.00.00 -->
Open Graph, Twitter Card, and social sharing meta tag management for Joomla 4/5/6.
## Overview
MokoSuiteOpenGraph gives you full control over how your Joomla content appears when shared on Facebook, Twitter/X, LinkedIn, Discord, WhatsApp, Telegram, and other social platforms. Set custom titles, descriptions, and images per article, menu item, and category — or let the extension auto-generate them from your existing content.
MokoOpenGraph gives you full control over how your Joomla content appears when shared on Facebook, Twitter/X, LinkedIn, WhatsApp, and other social platforms. Set custom titles, descriptions, and images per article and menu item — or let the extension auto-generate them from your existing content.
## Features
### Social Meta Tags
- **Open Graph tags** — `og:title`, `og:description`, `og:image`, `og:url`, `og:type`, `og:site_name`, `og:locale`
- **Open Graph tags** — `og:title`, `og:description`, `og:image`, `og:url`, `og:type`, `og:site_name`
- **Twitter/X Cards** — Summary and Summary with Large Image card types
- **LinkedIn** — `article:published_time`, `article:modified_time`, `article:author`
- **Discord** — Custom embed color via `theme-color` meta tag
- **Telegram** — `telegram:channel` for link previews
- **Facebook** — `fb:app_id` support, `og:image:width`/`og:image:height` for instant previews
### Content Management
- **Per-article control** — Custom OG fields tab in the article editor
- **Per-article control** — Custom OG fields in the article editor
- **Per-menu-item control** — Custom OG fields in the menu item editor
- **Per-category control** — Category-level OG tag overrides
- **Multilingual support** — Per-language OG data with language-aware fallback
- **Auto-generation** — Builds tags from article content, title, and images automatically
- **Site-wide defaults** — Default OG title, description, and image for all pages
### SEO
- **SEO title override** — Custom `<title>` tag per page
- **Meta description** — Per-page meta description control
- **Robots directive** — Per-page noindex/nofollow settings
- **Canonical URL** — Custom canonical URL overrides
- **JSON-LD structured data** — Article, Product, WebPage, BreadcrumbList, Organization schemas
### Admin Tools
- **Tag manager dashboard** — View and manage all OG records centrally
- **Batch generation** — Auto-generate OG tags for all existing articles
- **CSV import/export** — Bulk manage OG data via CSV files
- **SEO health badges** — Visual indicators for missing descriptions, long titles, noindex
- **Debug links** — Quick links to Facebook Debugger, LinkedIn Inspector, Google Rich Results
- **Live preview** — Real-time Facebook and Twitter/X card preview in the editor
### Developer Features
- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`)
- **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta
- **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags
- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630
- **Auto-generation** — Automatically builds tags from article content, title, and images
- **Default fallback image** — Site-wide default when no article image exists
- **Admin tag manager** — View and manage all OG records from a central dashboard
- **Facebook App ID** — Optional `fb:app_id` meta tag support
- **Joomla 4/5/6** — Modern DI container architecture, Joomla coding standards
## Installation
1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/releases)
1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases)
2. In Joomla Administrator → Extensions → Install → Upload Package File
3. All plugins are enabled automatically on install
3. The system plugin is enabled automatically on install
## Configuration
Navigate to **Extensions → Plugins → System - MokoSuiteOpenGraph** to configure:
Navigate to **Extensions → Plugins → System - MokoOpenGraph** to configure:
- Site name override
- Default OG title and description (site-wide fallback)
- Default fallback image
- Twitter Card type and @username
- Facebook App ID
- Discord embed color
- Telegram channel
- Auto-generation, image resize, JSON-LD, and description length settings
- Auto-generation behavior
- Description length limit
## License
+1 -3
View File
@@ -17,10 +17,8 @@
"require-dev": {
"squizlabs/php_codesniffer": "^3.7",
"phpstan/phpstan": "^1.10",
"joomla/coding-standards": "^3.0"
"joomla/coding-standards": "^4.0"
},
"minimum-stability": "alpha",
"prefer-stable": true,
"config": {
"sort-packages": true
}
-1
View File
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
-1
View File
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
-1
View File
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
-1
View File
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,68 +0,0 @@
<?php
/**
* @package MokoJoomOpenGraph
* @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 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,66 +0,0 @@
<?php
/**
* @package MokoJoomOpenGraph
* @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',
];
}
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
-1
View File
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,10 +0,0 @@
--
-- MokoJoomOpenGraph 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,5 +0,0 @@
--
-- MokoJoomOpenGraph 01.03.00 - Add og_video column
--
ALTER TABLE `#__mokoog_tags` ADD COLUMN `og_video` VARCHAR(512) NOT NULL DEFAULT '' AFTER `og_type`;
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,255 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
class ImportExportController extends BaseController
{
/**
* Maximum upload file size in bytes (2 MB).
*/
private const MAX_FILE_SIZE = 2 * 1024 * 1024;
/**
* Allowed content_type patterns for import.
*/
private const CONTENT_TYPE_PATTERN = '/^[a-z][a-z0-9_.]*$/';
/**
* Export all OG tags as CSV.
*
* @return void
*/
public function export(): void
{
Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN'));
if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
}
$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'),
$db->quoteName('t.language'),
])
->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',
'language',
]);
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'));
$identity = Factory::getApplication()->getIdentity();
if (!$identity->authorise('core.create', 'com_mokoog') || !$identity->authorise('core.edit', 'com_mokoog')) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403);
}
$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;
}
$csvFile = $files['csv_file'];
// Validate file extension
$ext = strtolower(pathinfo($csvFile['name'] ?? '', PATHINFO_EXTENSION));
if ($ext !== 'csv') {
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error');
$app->redirect('index.php?option=com_mokoog&view=tags');
return;
}
// Validate MIME type
$allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel'];
if (!empty($csvFile['type']) && !\in_array($csvFile['type'], $allowedMimes, true)) {
$app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error');
$app->redirect('index.php?option=com_mokoog&view=tags');
return;
}
// Validate file size
if (($csvFile['size'] ?? 0) > self::MAX_FILE_SIZE) {
$app->enqueueMessage(Text::sprintf('COM_MOKOOG_IMPORT_FILE_TOO_LARGE', '2 MB'), 'error');
$app->redirect('index.php?option=com_mokoog&view=tags');
return;
}
$tmpFile = $csvFile['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] ?? '');
$language = trim($row[11] ?? '*');
// Validate language tag format (e.g., 'en-GB', '*')
if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) {
$language = '*';
}
if (empty($contentType) || $contentId <= 0) {
$skipped++;
continue;
}
// Validate content_type against allowed pattern
if (!preg_match(self::CONTENT_TYPE_PATTERN, $contentType)) {
$skipped++;
continue;
}
// Check for existing record (unique key includes language)
$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)
->where($db->quoteName('language') . ' = ' . $db->quote($language));
$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,
'language' => $language,
'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,33 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class TagsController extends AdminController
{
/**
* Proxy for getModel.
*
* @param string $name Model name
* @param string $prefix Model prefix
* @param array $config Configuration array
*
* @return BaseDatabaseModel
*/
public function getModel($name = 'Tag', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
}
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,68 +0,0 @@
<?php
/**
* @package MokoJoomOpenGraph
* @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);
}
}
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,107 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage com_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoOG\Administrator\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
class TagTable extends Table
{
/**
* Constructor.
*
* @param DatabaseDriver $db Database driver instance
*/
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokoog_tags', 'id', $db);
}
/**
* Perform checks before store.
*
* @return bool
*/
private const VALID_OG_TYPES = [
'article', 'website', 'product', 'profile', 'book', 'music.song',
'music.album', 'video.movie', 'video.episode', 'video.other',
];
private const VALID_ROBOTS = [
'index', 'noindex', 'follow', 'nofollow', 'none', 'noarchive',
'nosnippet', 'noimageindex', 'max-snippet', 'max-image-preview',
];
public function check(): bool
{
if (empty($this->content_type)) {
$this->setError('Content type is required.');
return false;
}
if (!preg_match('/^[a-z][a-z0-9_.]*$/', $this->content_type)) {
$this->setError('Content type contains invalid characters.');
return false;
}
if (empty($this->content_id)) {
$this->setError('Content ID is required.');
return false;
}
// Validate og_type against known values
if (!empty($this->og_type) && !\in_array($this->og_type, self::VALID_OG_TYPES, true)) {
$this->og_type = 'article';
}
// Truncate fields to schema max lengths
if (mb_strlen($this->og_title ?? '') > 255) {
$this->og_title = mb_substr($this->og_title, 0, 255);
}
if (mb_strlen($this->seo_title ?? '') > 70) {
$this->seo_title = mb_substr($this->seo_title, 0, 70);
}
if (mb_strlen($this->meta_description ?? '') > 200) {
$this->meta_description = mb_substr($this->meta_description, 0, 200);
}
// Validate canonical_url format if non-empty
if (!empty($this->canonical_url) && !filter_var($this->canonical_url, FILTER_VALIDATE_URL)) {
$this->canonical_url = '';
}
// Validate robots directives
if (!empty($this->robots)) {
$parts = array_map('trim', explode(',', strtolower($this->robots)));
$valid = array_filter($parts, function ($part) {
// Allow directives with values like "max-snippet:-1"
$directive = explode(':', $part)[0];
return \in_array($directive, self::VALID_ROBOTS, true);
});
$this->robots = $valid ? implode(', ', $valid) : '';
}
// Default language to '*' if not set
if (empty($this->language)) {
$this->language = '*';
}
return true;
}
}
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
-1
View File
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,147 +0,0 @@
/**
* @package MokoJoomOpenGraph
* @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;
}
/* LinkedIn card */
.mokoog-card-li {
border: 1px solid #e0dfdc;
border-radius: 2px;
}
.mokoog-card-li .mokoog-card-body {
border-top-color: #e0dfdc;
}
.mokoog-card-li .mokoog-card-title {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.9);
}
.mokoog-card-li .mokoog-card-domain {
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
text-transform: none;
}
/* Character count indicators */
.mokoog-char-count {
display: block;
font-size: 12px;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.mokoog-char-ok {
color: #2e7d32;
}
.mokoog-char-warn {
color: #f57c00;
}
.mokoog-char-over {
color: #d32f2f;
font-weight: 600;
}
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,20 +0,0 @@
{
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
"name": "plg_content_mokoog",
"version": "01.00.00",
"description": "MokoJoomOpenGraph 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 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,247 +0,0 @@
/**
* @package MokoJoomOpenGraph
* @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'),
seoTitle: document.getElementById('jform_mokoog_seo_title'),
metaDescription: document.getElementById('jform_mokoog_meta_description')
};
// Character count indicators
var charLimits = [
{ field: fields.ogTitle, optimal: 60, max: 90 },
{ field: fields.ogDesc, optimal: 155, max: 200 },
{ field: fields.seoTitle, optimal: 60, max: 70 },
{ field: fields.metaDescription, optimal: 155, max: 160 }
];
charLimits.forEach(function (cfg) {
if (!cfg.field) return;
var counter = document.createElement('span');
counter.className = 'mokoog-char-count';
cfg.field.parentNode.appendChild(counter);
function refresh() {
var len = cfg.field.value.length;
counter.textContent = len + '/' + cfg.optimal;
if (len > cfg.max) {
counter.className = 'mokoog-char-count mokoog-char-over';
} else if (len > cfg.optimal) {
counter.className = 'mokoog-char-count mokoog-char-warn';
} else {
counter.className = 'mokoog-char-count mokoog-char-ok';
}
}
cfg.field.addEventListener('input', refresh);
cfg.field.addEventListener('change', refresh);
refresh();
});
// 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);
// LinkedIn preview card
var liLabel = document.createElement('small');
liLabel.className = 'mokoog-platform-label';
liLabel.textContent = 'LinkedIn';
wrapper.appendChild(liLabel);
var liCard = document.createElement('div');
liCard.className = 'mokoog-card mokoog-card-li';
var liImg = document.createElement('div');
liImg.id = 'mokoog-li-img';
liImg.className = 'mokoog-card-img';
liCard.appendChild(liImg);
var liBody = document.createElement('div');
liBody.className = 'mokoog-card-body';
var liTitle = document.createElement('div');
liTitle.id = 'mokoog-li-title';
liTitle.className = 'mokoog-card-title';
liBody.appendChild(liTitle);
var liDomain = document.createElement('div');
liDomain.id = 'mokoog-li-domain';
liDomain.className = 'mokoog-card-domain';
liBody.appendChild(liDomain);
liCard.appendChild(liBody);
wrapper.appendChild(liCard);
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';
}
// LinkedIn (shorter truncation: title 70, no description shown in card)
var liTitle = title.length > 70 ? title.substring(0, 67) + '...' : title;
document.getElementById('mokoog-li-title').textContent = liTitle;
document.getElementById('mokoog-li-domain').textContent = domain;
var liImgEl = document.getElementById('mokoog-li-img');
if (img) {
liImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
liImgEl.style.display = '';
} else {
liImgEl.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 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,751 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Plugin\System\MokoOG\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
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
{
/**
* @var bool
*/
protected $autoloadLanguage = true;
/**
* Returns the events this plugin subscribes to.
*
* @return array<string, string>
*/
public static function getSubscribedEvents(): array
{
return [
'onAfterRoute' => 'onAfterRoute',
'onBeforeCompileHead' => 'onBeforeCompileHead',
];
}
/**
* Run admin-side license key check after routing.
*
* @param Event $event The event object
*
* @return void
*/
public function onAfterRoute(Event $event): void
{
$app = $this->getApplication();
if ($app->isClient('administrator')) {
$this->warnMissingLicenseKey();
}
}
/**
* Inject Open Graph and Twitter Card meta tags before the document head is compiled.
*
* @param Event $event The event object
*
* @return void
*/
public function onBeforeCompileHead(Event $event): void
{
$app = $this->getApplication();
// Only run on the site frontend
if (!$app->isClient('site')) {
return;
}
$doc = $app->getDocument();
if ($doc->getType() !== 'html') {
return;
}
$input = $app->getInput();
$option = $input->getCmd('option', '');
$view = $input->getCmd('view', '');
$id = $input->getInt('id', 0);
// 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', 'og_video', '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 OG data → site-wide defaults → auto-generated
$defaultTitle = $this->params->get('default_og_title', '');
$defaultDesc = $this->params->get('default_og_description', '');
$title = $ogData->og_title ?: ($doc->getTitle() ?: $defaultTitle);
$description = $ogData->og_description ?: ($this->buildDescription($doc) ?: $defaultDesc);
$image = $ogData->og_image ?: $this->findImage($option, $view, $id);
$url = Uri::getInstance()->toString();
$siteName = $this->params->get('og_site_name', $app->get('sitename', ''));
$defaultType = ($option === 'com_mokoshop' && $view === 'product') ? 'product' : 'article';
$type = $ogData->og_type ?: $defaultType;
// Open Graph tags
$doc->setMetaData('og:title', $title, 'property');
$doc->setMetaData('og:description', $description, 'property');
$doc->setMetaData('og:url', $url, 'property');
$doc->setMetaData('og:type', $type, 'property');
$doc->setMetaData('og:site_name', $siteName, 'property');
if ($image) {
$imageUrl = $this->resolveImageUrl($image);
$doc->setMetaData('og:image', $imageUrl, 'property');
// Emit actual image dimensions when detectable
$imageDims = $this->getImageDimensions($image);
if ($imageDims) {
$doc->setMetaData('og:image:width', (string) $imageDims[0], 'property');
$doc->setMetaData('og:image:height', (string) $imageDims[1], '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', '');
if ($fbAppId) {
$doc->setMetaData('fb:app_id', $fbAppId, 'property');
}
// Twitter Card tags
$cardType = $this->params->get('twitter_card_type', 'summary_large_image');
$twitterSite = $this->params->get('twitter_site', '');
$doc->setMetaData('twitter:card', $cardType);
$doc->setMetaData('twitter:title', $title);
$doc->setMetaData('twitter:description', $description);
if ($image) {
$doc->setMetaData('twitter:image', $this->resolveImageUrl($image));
}
if ($twitterSite) {
$doc->setMetaData('twitter:site', $twitterSite);
}
// Telegram channel tag
$telegramChannel = $this->params->get('telegram_channel', '');
if ($telegramChannel) {
$doc->setMetaData('telegram:channel', $telegramChannel);
}
// Discord embed color (theme-color meta tag)
$discordColor = $this->params->get('discord_color', '');
if ($discordColor) {
$doc->setMetaData('theme-color', $discordColor);
}
// Fediverse/Mastodon creator attribution
$fediverseCreator = $this->params->get('fediverse_creator', '');
if ($fediverseCreator) {
$doc->setMetaData('fediverse:creator', $fediverseCreator);
}
// og:video tags
$videoUrl = $ogData->og_video ?? '';
if ($videoUrl) {
$doc->setMetaData('og:video', $videoUrl, 'property');
if (str_starts_with($videoUrl, 'https://')) {
$doc->setMetaData('og:video:secure_url', $videoUrl, 'property');
}
// Detect video type from URL — embeds vs direct files
$isEmbed = str_contains($videoUrl, 'youtube.com') || str_contains($videoUrl, 'youtu.be')
|| str_contains($videoUrl, 'vimeo.com');
if ($isEmbed) {
$doc->setMetaData('og:video:type', 'text/html', 'property');
} else {
$ext = strtolower(pathinfo(parse_url($videoUrl, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION));
$mimeMap = ['mp4' => 'video/mp4', 'webm' => 'video/webm', 'ogg' => 'video/ogg'];
$doc->setMetaData('og:video:type', $mimeMap[$ext] ?? 'video/mp4', 'property');
$doc->setMetaData('og:video:width', '1280', 'property');
$doc->setMetaData('og:video:height', '720', 'property');
}
}
// LinkedIn article tags
if ($option === 'com_content' && $view === 'article' && $id > 0) {
$doc->setMetaData('article:published_time', $this->getArticleDate($id, 'publish_up'), 'property');
$doc->setMetaData('article:modified_time', $this->getArticleDate($id, 'modified'), 'property');
$author = $this->getArticleAuthor($id);
if ($author) {
$doc->setMetaData('article:author', $author, 'property');
}
}
// MokoSuiteShop product meta tags (pricing + Pinterest availability)
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
$productData = $this->loadShopProduct($id);
if ($productData) {
$doc->setMetaData('product:price:amount', number_format((float) $productData->price, 2, '.', ''), 'property');
$doc->setMetaData('product:price:currency', $productData->currency ?: 'USD', 'property');
$availability = ((int) ($productData->stock_qty ?? 0) > 0) ? 'instock' : 'outofstock';
$doc->setMetaData('product:availability', $availability, 'property');
}
}
// Pinterest article:tag rich pins (from Joomla content tags)
if ($option === 'com_content' && $view === 'article' && $id > 0) {
$db = Factory::getDbo();
$tagQuery = $db->getQuery(true)
->select($db->quoteName('t.title'))
->from($db->quoteName('#__tags', 't'))
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm')
. ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'))
->where($db->quoteName('m.content_item_id') . ' = ' . $id)
->where($db->quoteName('t.published') . ' = 1');
$db->setQuery($tagQuery);
$tags = $db->loadColumn();
foreach ($tags as $tag) {
$doc->setMetaData('article:tag', $tag, 'property');
}
}
// Fire event so third-party plugins can add custom OG/social tags
$eventData = [
'subject' => $doc,
'title' => $title,
'description' => $description,
'image' => $image ? $this->resolveImageUrl($image) : '',
'url' => $url,
'type' => $type,
'option' => $option,
'view' => $view,
'id' => $id,
];
$app->getDispatcher()->dispatch('onMokoOGAfterRender', new Event('onMokoOGAfterRender', $eventData));
// JSON-LD structured data
if ($this->params->get('jsonld_enabled', 1)) {
$imageUrl = $image ? $this->resolveImageUrl($image) : '';
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
$schema = JsonLdBuilder::buildProduct($id, $title, $description, $imageUrl, $this->loadShopProduct($id));
} elseif ($option === 'com_content' && $view === 'article' && $id > 0) {
$schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl, $this->loadArticle($id));
} 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 via public API
$headData = $doc->getHeadData();
if (!empty($headData['links'])) {
foreach ($headData['links'] as $link => $attribs) {
if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') {
unset($headData['links'][$link]);
}
}
$doc->setHeadData($headData);
}
$doc->addHeadLink($ogData->canonical_url, 'canonical');
}
}
/**
* Load custom OG data from the database for the current page.
*
* @param string $option Component option
* @param string $view View name
* @param int $id Content ID
*
* @return object
*/
private function loadOgData(string $option, string $view, int $id): object
{
$empty = (object) [
'og_title' => '',
'og_description' => '',
'og_image' => '',
'og_type' => '',
'og_video' => '',
'seo_title' => '',
'meta_description' => '',
'robots' => '',
'canonical_url' => '',
];
if (!$id) {
// Try menu-item-based lookup
$menuItem = $this->getApplication()->getMenu()->getActive();
if (!$menuItem) {
return $empty;
}
return $this->loadOgDataByMenu((int) $menuItem->id) ?: $empty;
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->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();
$lang = Factory::getLanguage()->getTag();
$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')
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang)
. ' 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();
}
/**
* Load OG data by menu item ID.
*
* @param int $menuId Menu item ID
*
* @return object|null
*/
private function loadOgDataByMenu(int $menuId): ?object
{
$db = Factory::getDbo();
$lang = Factory::getLanguage()->getTag();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokoog_tags'))
->where($db->quoteName('content_type') . ' = ' . $db->quote('menu'))
->where($db->quoteName('content_id') . ' = ' . $menuId)
->where($db->quoteName('published') . ' = 1')
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang)
. ' 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();
}
/**
* Build a description from the document metadata or page content.
*
* @param \Joomla\CMS\Document\HtmlDocument $doc The document
*
* @return string
*/
private function buildDescription($doc): string
{
$description = $doc->getDescription();
$maxLength = (int) $this->params->get('desc_length', 160);
if ($this->params->get('strip_html', 1)) {
$description = strip_tags($description);
}
$description = trim(preg_replace('/\s+/', ' ', $description));
if (mb_strlen($description) > $maxLength) {
$description = mb_substr($description, 0, $maxLength - 3) . '...';
}
return $description;
}
/**
* Attempt to find the first image for the given content.
*
* @param string $option Component option
* @param string $view View name
* @param int $id Content ID
*
* @return string
*/
private function findImage(string $option, string $view, int $id): string
{
if (!$this->params->get('auto_generate', 1)) {
return $this->params->get('default_image', '');
}
// For MokoSuiteShop products, look at the linked article's images
if ($option === 'com_mokoshop' && $id > 0) {
$productData = $this->loadShopProduct($id);
if ($productData && !empty($productData->images)) {
$imagesData = json_decode($productData->images, true);
if (!empty($imagesData['image_fulltext'])) {
return $imagesData['image_fulltext'];
}
if (!empty($imagesData['image_intro'])) {
return $imagesData['image_intro'];
}
}
return $this->params->get('default_image', '');
}
// For Joomla articles, look at the intro/full image fields
if ($option === 'com_content' && $id > 0) {
$article = $this->loadArticle($id);
if ($article && !empty($article->images)) {
$imagesData = json_decode($article->images, true);
if (!empty($imagesData['image_fulltext'])) {
return $imagesData['image_fulltext'];
}
if (!empty($imagesData['image_intro'])) {
return $imagesData['image_intro'];
}
}
// Fallback: check the article's category for an image
if ($view === 'article') {
$db = Factory::getDbo();
$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.
*
* @param string $image Image path
*
* @return string
*/
private function resolveImageUrl(string $image): string
{
if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) {
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, '/');
}
/**
* Load and cache a full article record with author for the current request.
*
* @param int $id Article ID
*
* @return object|null
*/
private function loadArticle(int $id): ?object
{
static $cache = [];
if (isset($cache[$id])) {
return $cache[$id];
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'a.title', 'a.introtext', 'a.fulltext', 'a.images',
'a.created', 'a.modified', 'a.publish_up', 'a.metadesc',
]))
->select($db->quoteName('u.name', 'author_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') . ' = ' . $id);
$db->setQuery($query);
$cache[$id] = $db->loadObject();
return $cache[$id];
}
/**
* Get a date field from an article.
*
* @param int $id Article ID
* @param string $field Date field name (publish_up, modified, created)
*
* @return string ISO 8601 date string
*/
private function getArticleDate(int $id, string $field): string
{
$article = $this->loadArticle($id);
return $article->$field ?? '';
}
/**
* Get the author name for an article.
*
* @param int $id Article ID
*
* @return string
*/
private function getArticleAuthor(int $id): string
{
$article = $this->loadArticle($id);
return $article->author_name ?? '';
}
/**
* Warn administrators once per session when no license key is configured.
*
* @return void
*/
private function warnMissingLicenseKey(): void
{
$session = Factory::getSession();
if ($session->get('mokoog.license_warned', false)) {
return;
}
$user = Factory::getUser();
if ($user->guest || !$user->authorise('core.manage')) {
return;
}
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('extra_query'))
->from($db->quoteName('#__update_sites'))
->where($db->quoteName('name') . ' = ' . $db->quote('MokoSuiteOpenGraph Updates'))
->setLimit(1);
$db->setQuery($query);
$extraQuery = (string) $db->loadResult();
// Mark as checked only after the DB query succeeds
$session->set('mokoog.license_warned', true);
if (!empty($extraQuery)) {
parse_str($extraQuery, $parsed);
if (!empty($parsed['dlid']) && preg_match('/^MOKO-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $parsed['dlid'])) {
return;
}
}
$this->getApplication()->enqueueMessage(
'<strong>Moko Consulting License Key Required</strong> — '
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
. 'and enter your license key (<code>MOKO-XXXX-XXXX-XXXX-XXXX</code>) in the Download Key field '
. 'for the MokoSuiteOpenGraph update site.',
'warning'
);
} catch (\Throwable $e) {
// Don't break admin over a license check, but log for debugging
\Joomla\CMS\Log\Log::add('MokoOG license check: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
}
}
/**
* Load MokoSuiteShop product data by product ID.
*
* @param int $productId CRM product ID
*
* @return object|null Product with name, description, images, price, currency, sku
*/
private function loadShopProduct(int $productId): ?object
{
static $cache = [];
if (isset($cache[$productId])) {
return $cache[$productId];
}
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('p.id, p.sku, p.price, p.currency, p.stock_qty')
->select('c.title AS name, c.introtext AS description, c.images')
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
->join('LEFT', $db->quoteName('#__content', 'c') . ' ON c.id = p.article_id')
->where($db->quoteName('p.id') . ' = ' . $productId)
->where($db->quoteName('p.published') . ' = 1');
$db->setQuery($query);
$cache[$productId] = $db->loadObject();
} catch (\RuntimeException $e) {
// MokoSuiteShop tables may not exist
$cache[$productId] = null;
}
return $cache[$productId];
}
/**
* Get the actual pixel dimensions of a local image.
*
* @param string $image Image path (relative or absolute URL)
*
* @return array{0: int, 1: int}|null
*/
private function getImageDimensions(string $image): ?array
{
// Cannot determine dimensions for external URLs
if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) {
return null;
}
// If auto-resize is on, the resized image lives in the generated dir
if ($this->params->get('auto_resize', 1)) {
$resolved = ImageHelper::resize($image);
} else {
$resolved = $image;
}
$absPath = JPATH_ROOT . '/' . ltrim($resolved, '/');
if (!is_file($absPath)) {
return null;
}
$info = getimagesize($absPath);
if (!$info) {
return null;
}
return [$info[0], $info[1]];
}
}
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,182 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Plugin\System\MokoOG\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Filesystem\Folder;
use Joomla\CMS\Log\Log;
class ImageGenerator
{
private const WIDTH = 1200;
private const HEIGHT = 630;
private const OUTPUT_DIR = 'images/mokoog/generated';
/**
* Generate an OG image with title text overlaid on a template background.
*
* @param string $title Article title to overlay
* @param string $templateImage Path to template/background image relative to JPATH_ROOT
* @param string $fontFile Absolute path to TTF font file
* @param int $fontSize Font size in points (default 42)
* @param array $fontColor RGB array [r, g, b] (default white)
* @param int $quality JPEG quality (default 90)
*
* @return string Path to generated image relative to JPATH_ROOT, or empty on failure
*/
public static function generate(
string $title,
string $templateImage,
string $fontFile = '',
int $fontSize = 42,
array $fontColor = [255, 255, 255],
int $quality = 90
): string {
if (!\extension_loaded('gd')) {
Log::add('MokoOG ImageGenerator: GD extension is not loaded. Image generation disabled.', Log::WARNING, 'mokoog');
return '';
}
$templateAbs = JPATH_ROOT . '/' . ltrim($templateImage, '/');
if (!is_file($templateAbs)) {
Log::add('MokoOG ImageGenerator: Template image not found: ' . $templateImage, Log::WARNING, 'mokoog');
return '';
}
if (!$fontFile || !is_file($fontFile)) {
Log::add('MokoOG ImageGenerator: TTF font file not found: ' . ($fontFile ?: '(not configured)'), Log::WARNING, 'mokoog');
return '';
}
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
Log::add('MokoOG ImageGenerator: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
return '';
}
$hash = md5($title . $templateImage . $fontSize);
$outputName = 'overlay_' . $hash . '.jpg';
$outputPath = $outputDir . '/' . $outputName;
$outputRel = self::OUTPUT_DIR . '/' . $outputName;
// Skip if already generated
if (is_file($outputPath)) {
return $outputRel;
}
// Load template image
$imageInfo = getimagesize($templateAbs);
if (!$imageInfo) {
Log::add('MokoOG ImageGenerator: Cannot read image dimensions: ' . $templateImage, Log::WARNING, 'mokoog');
return '';
}
$source = match ($imageInfo[2]) {
IMAGETYPE_JPEG => imagecreatefromjpeg($templateAbs),
IMAGETYPE_PNG => imagecreatefrompng($templateAbs),
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($templateAbs) : false,
default => false,
};
if (!$source) {
Log::add('MokoOG ImageGenerator: Failed to load image (unsupported type or corrupt): ' . $templateImage, Log::WARNING, 'mokoog');
return '';
}
// Create output canvas at target dimensions
$canvas = imagecreatetruecolor(self::WIDTH, self::HEIGHT);
imagecopyresampled(
$canvas,
$source,
0, 0, 0, 0,
self::WIDTH, self::HEIGHT,
$imageInfo[0], $imageInfo[1]
);
imagedestroy($source);
// Semi-transparent overlay for text readability
$overlay = imagecolorallocatealpha($canvas, 0, 0, 0, 64);
imagefilledrectangle($canvas, 0, (int) (self::HEIGHT * 0.55), self::WIDTH, self::HEIGHT, $overlay);
// Render title text with word wrapping
$textColor = imagecolorallocate($canvas, $fontColor[0], $fontColor[1], $fontColor[2]);
$wrappedTitle = self::wrapText($title, $fontFile, $fontSize, (int) (self::WIDTH * 0.85));
$textX = (int) (self::WIDTH * 0.075);
$textY = (int) (self::HEIGHT * 0.72);
imagettftext($canvas, $fontSize, 0, $textX, $textY, $textColor, $fontFile, $wrappedTitle);
// Save
imagejpeg($canvas, $outputPath, $quality);
imagedestroy($canvas);
return $outputRel;
}
/**
* Wrap text to fit within a maximum pixel width.
*
* @param string $text Text to wrap
* @param string $fontFile Path to TTF font
* @param int $fontSize Font size in points
* @param int $maxWidth Maximum width in pixels
*
* @return string Wrapped text with newlines
*/
private static function wrapText(string $text, string $fontFile, int $fontSize, int $maxWidth): string
{
$words = explode(' ', $text);
$lines = [];
$line = '';
foreach ($words as $word) {
$testLine = $line ? $line . ' ' . $word : $word;
$bbox = imagettfbbox($fontSize, 0, $fontFile, $testLine);
$lineWidth = abs($bbox[4] - $bbox[0]);
if ($lineWidth > $maxWidth && $line !== '') {
$lines[] = $line;
$line = $word;
} else {
$line = $testLine;
}
}
if ($line !== '') {
$lines[] = $line;
}
// Limit to 3 lines, truncate last line if needed
if (\count($lines) > 3) {
$lines = \array_slice($lines, 0, 3);
if (mb_strlen($lines[2]) > 3) {
$lines[2] = mb_substr($lines[2], 0, -3) . '...';
} else {
$lines[2] .= '...';
}
}
return implode("\n", $lines);
}
}
@@ -1,267 +0,0 @@
<?php
/**
* @package MokoSuiteOpenGraph
* @subpackage plg_system_mokoog
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Plugin\System\MokoOG\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\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)
* @param object|null $cachedArticle Pre-loaded article data (avoids duplicate query)
*
* @return array|null
*/
public static function buildArticle(int $articleId, string $title, string $description, string $image, ?object $cachedArticle = null): ?array
{
if ($articleId <= 0) {
return null;
}
$article = $cachedArticle;
if (!$article) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName([
'a.created', 'a.modified', 'a.publish_up',
]))
->select($db->quoteName('u.name', 'author_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,
];
$authorName = $article->author_name ?? '';
if (!empty($authorName)) {
$schema['author'] = [
'@type' => 'Person',
'name' => $authorName,
];
}
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(),
];
}
/**
* Build Product schema for a MokoSuiteShop product.
*
* @param int $productId CRM product ID
* @param string $title Product title
* @param string $description Product description
* @param string $image Image URL (absolute)
* @param object|null $cachedProduct Pre-loaded product data (avoids duplicate query)
*
* @return array|null
*/
public static function buildProduct(int $productId, string $title, string $description, string $image, ?object $cachedProduct = null): ?array
{
if ($productId <= 0) {
return null;
}
$product = $cachedProduct;
if (!$product) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('p.sku, p.price, p.currency, p.stock_qty')
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
->where($db->quoteName('p.id') . ' = ' . $productId)
->where($db->quoteName('p.published') . ' = 1');
$db->setQuery($query);
$product = $db->loadObject();
}
if (!$product) {
return null;
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $title,
'description' => $description,
'url' => Uri::getInstance()->toString(),
];
if (!empty($product->sku)) {
$schema['sku'] = $product->sku;
}
if (!empty($image)) {
$schema['image'] = $image;
}
// Offers (pricing and availability)
$availability = ((float) $product->stock_qty > 0)
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock';
$schema['offers'] = [
'@type' => 'Offer',
'price' => number_format((float) $product->price, 2, '.', ''),
'priceCurrency' => $product->currency ?: 'USD',
'availability' => $availability,
'url' => Uri::getInstance()->toString(),
];
// Aggregate rating from reviews if available
try {
$reviewQuery = $db->getQuery(true)
->select('COUNT(*) AS review_count, AVG(rating) AS avg_rating')
->from($db->quoteName('#__mokoshop_reviews'))
->where($db->quoteName('product_id') . ' = ' . $productId)
->where($db->quoteName('status') . ' = ' . $db->quote('approved'));
$db->setQuery($reviewQuery);
$rating = $db->loadObject();
if ($rating && (int) $rating->review_count > 0) {
$schema['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => round((float) $rating->avg_rating, 1),
'reviewCount' => (int) $rating->review_count,
];
}
} catch (\RuntimeException $e) {
// Reviews table may not exist if MokoSuiteShop reviews module not installed
}
return $schema;
}
/**
* 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);
// Escape </ sequences to prevent XSS via </script> in content data
$json = str_replace('</', '<\/', $json);
return '<script type="application/ld+json">' . $json . '</script>';
}
}
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1 +0,0 @@
<html><body bgcolor="#FFFFFF"></body></html>
@@ -1,5 +0,0 @@
; MokoJoomOpenGraph - Web Services Plugin Language File
; Copyright (C) 2026 Moko Consulting. All rights reserved.
; License: GPL-3.0-or-later
PLG_WEBSERVICES_MOKOOG="Web Services - MokoJoomOpenGraph"

Some files were not shown because too many files have changed in this diff Show More