Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d724802251 | |||
| c4425bb08a | |||
| f5068a51ea | |||
| 2990726057 | |||
| 37ade0f6cf |
@@ -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
|
||||
-205
@@ -1,205 +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.cache/
|
||||
.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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Moko Platform Repository Manifest
|
||||
See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home
|
||||
-->
|
||||
<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>
|
||||
<last-synced>2026-05-23T22:16:00+00:00</last-synced>
|
||||
</governance>
|
||||
<build>
|
||||
<language>PHP</language>
|
||||
<package-type>joomla-extension</package-type>
|
||||
<entry-point>src/</entry-point>
|
||||
</build>
|
||||
</moko-platform>
|
||||
@@ -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"
|
||||
@@ -4,51 +4,36 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +=======================================================================+
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +=======================================================================+
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, type-prefixed packages |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +=======================================================================+
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, closed]
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.mokogitea/workflows/**'
|
||||
- '*.md'
|
||||
- 'wiki/**'
|
||||
- '.editorconfig'
|
||||
- '.gitignore'
|
||||
- '.gitattributes'
|
||||
- '.gitmessage'
|
||||
- 'LICENSE'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: 'Action to perform'
|
||||
required: false
|
||||
type: choice
|
||||
default: release
|
||||
options:
|
||||
- release
|
||||
- promote-rc
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -60,344 +45,534 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
|
||||
promote-rc:
|
||||
name: Promote to RC
|
||||
runs-on: release
|
||||
if: >-
|
||||
(github.event.action == 'opened' && github.event.pull_request.merged != true) ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup mokocli tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/version_bump.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: Rename branch to rc
|
||||
run: |
|
||||
php ${MOKO_CLI}/branch_rename.php \
|
||||
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
|
||||
--pr "${{ github.event.pull_request.number }}"
|
||||
|
||||
- name: Checkout rc and configure git
|
||||
run: |
|
||||
git fetch origin rc
|
||||
git checkout rc
|
||||
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"
|
||||
|
||||
- name: Publish RC release
|
||||
run: |
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability rc --bump minor --branch rc \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: Update RC release notes from CHANGELOG.md
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Release candidate"
|
||||
|
||||
# Find the RC release and update its body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/release-candidate" \
|
||||
| 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 ${TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "RC release notes updated from CHANGELOG.md"
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: >-
|
||||
github.event.pull_request.merged == true ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
|
||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git for bot pushes
|
||||
run: |
|
||||
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"
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||
if [ -n "$CONFLICTS" ]; then
|
||||
echo "::error::Merge conflict markers found — aborting release"
|
||||
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
echo "No conflict markers found"
|
||||
|
||||
- name: Setup 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_MIRROR_TOKEN }}"}}'
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
|
||||
run: |
|
||||
if [ -f /opt/mokocli/cli/version_bump.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
|
||||
# Ensure PHP + Composer are available
|
||||
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"
|
||||
|
||||
# -- PLATFORM DETECTION ---------------------------------------------------
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
|
||||
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
|
||||
# Read platform from manifest.xml <platform> element; fallback to generic
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*//p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
echo "Platform detected: ${PLATFORM}"
|
||||
# For packages: prefer pkg_*.xml in src/; fallback to any manifest
|
||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Determine version bump level"
|
||||
id: bump
|
||||
run: |
|
||||
# Fix/patch branches: version was already bumped by pre-release, just strip suffix
|
||||
# Feature/dev branches: bump minor for the new stable release
|
||||
HEAD_REF="${{ github.event.pull_request.head.ref || 'dev' }}"
|
||||
case "$HEAD_REF" in
|
||||
fix/*|patch/*|hotfix/*|bugfix/*) BUMP="none" ;;
|
||||
*) BUMP="minor" ;;
|
||||
esac
|
||||
echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||
echo "Bump level: ${BUMP} (from branch: ${HEAD_REF})"
|
||||
|
||||
- name: "Publish stable release"
|
||||
run: |
|
||||
BUMP_FLAG=""
|
||||
if [ "${{ steps.bump.outputs.level }}" != "none" ]; then
|
||||
BUMP_FLAG="--bump ${{ steps.bump.outputs.level }}"
|
||||
fi
|
||||
php ${MOKO_CLI}/release_publish.php \
|
||||
--path . --stability stable ${BUMP_FLAG} --branch main \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
- name: "Read published version"
|
||||
# -- STEP 1: Read version -----------------------------------------------
|
||||
- name: "Step 1: Read version from README.md"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path . 2>/dev/null)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "No VERSION in README.md — skipping release"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Derive major.minor for branch naming (patches update existing branch)
|
||||
MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}')
|
||||
PATCH=$(echo "$VERSION" | awk -F. '{print $3}')
|
||||
|
||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
||||
MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}')
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
|
||||
echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
if [ "$PATCH" = "00" ] || [ "$PATCH" = "01" ]; then
|
||||
echo "is_minor=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION (first release for this minor — full pipeline)"
|
||||
else
|
||||
echo "is_minor=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION (patch — platform version + badges only)"
|
||||
fi
|
||||
|
||||
# -- STEP 1b: Bump minor version (stable = minor bump, reset patch) ------
|
||||
- name: "Step 1b: Bump minor version for stable release"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
id: bump
|
||||
run: |
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
CURRENT=$(php $CLI/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||
|
||||
# Minor bump via CLI (updates README.md in-place)
|
||||
BUMP_OUT=$(php $CLI/version_bump.php --path . --minor)
|
||||
VERSION=$(php $CLI/version_read.php --path . 2>/dev/null)
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
echo "Stable bump: ${BUMP_OUT}"
|
||||
|
||||
# Set platform-specific version (Joomla XML, Dolibarr mod*.class.php)
|
||||
php $CLI/version_set_platform.php --path . --version "$VERSION" --stability stable --branch main
|
||||
|
||||
# Promote [Unreleased] in CHANGELOG.md
|
||||
php $CLI/changelog_promote.php --path . --version "$VERSION" --date "$TODAY" 2>/dev/null || true
|
||||
|
||||
# Commit and push
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
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): bump ${CURRENT} → ${VERSION} [skip ci]"
|
||||
git push origin HEAD:main 2>&1
|
||||
}
|
||||
|
||||
# Override version output for rest of pipeline
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [[ "$PLATFORM" == joomla* ]]; then
|
||||
echo "tag=stable" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "branch=main" >> "$GITHUB_OUTPUT"
|
||||
echo "Published version: ${VERSION}"
|
||||
echo "major=${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Create semver tag for non-Joomla repos"
|
||||
id: semver
|
||||
if: |
|
||||
- name: Check if already released
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
|
||||
TAG_EXISTS=false
|
||||
BRANCH_EXISTS=false
|
||||
|
||||
git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
|
||||
git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
|
||||
|
||||
echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Tag and branch may persist across patch releases — never skip
|
||||
echo "already_released=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# -- SANITY CHECKS -------------------------------------------------------
|
||||
- name: "Sanity: Pre-release validation"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
!startsWith(steps.platform.outputs.platform, 'joomla')
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
SEMVER_TAG="v${VERSION}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
ERRORS=0
|
||||
|
||||
echo "Creating semver tag: ${SEMVER_TAG}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
echo "## Pre-Release Sanity Checks (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Create the git tag via API
|
||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/tags" \
|
||||
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Created semver tag: ${SEMVER_TAG}"
|
||||
elif [ "$HTTP_CODE" = "409" ]; then
|
||||
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
|
||||
# -- Version drift check (must pass before release) --------
|
||||
README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
||||
if [ "$README_VER" != "$VERSION" ]; then
|
||||
echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
|
||||
echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
|
||||
# Check CHANGELOG version matches
|
||||
CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
|
||||
if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
|
||||
echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
|
||||
- name: Update release notes and promote changelog
|
||||
# Check composer.json version if present
|
||||
if [ -f "composer.json" ]; then
|
||||
COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
|
||||
if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
|
||||
echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Common checks
|
||||
if [ ! -f "LICENSE" ]; then
|
||||
echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
|
||||
echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- Platform-specific checks --------
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
XML_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
|
||||
echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- No Joomla XML manifest (WaaS site)" >> $GITHUB_STEP_SUMMARY
|
||||
fi ;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_VER=$(sed -n "s/.*\\\$this->version = '\([^']*\)'.*/\1/p" "$MOD_FILE" 2>/dev/null | head -1)
|
||||
if [ -n "$MOD_VER" ] && [ "$MOD_VER" != "$VERSION" ]; then
|
||||
echo "- Module drift: \`${MOD_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Module version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
echo "- No mod*.class.php found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
if [ ! -f "update.txt" ]; then
|
||||
echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi ;;
|
||||
*) echo "- Generic platform � no manifest checks" >> $GITHUB_STEP_SUMMARY ;;
|
||||
esac
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 2: Create or update version/XX.YY archive branch ---------------
|
||||
# Always runs — every version change on main archives to version/XX.YY
|
||||
- name: "Step 2: Version archive branch"
|
||||
if: steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
IS_MINOR="${{ steps.version.outputs.is_minor }}"
|
||||
PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
|
||||
|
||||
# Check if branch exists
|
||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
||||
git push origin HEAD:"$BRANCH" --force
|
||||
echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
|
||||
git push origin "$BRANCH" --force
|
||||
echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 3: Set platform version ----------------------------------------
|
||||
- name: "Step 3: Set platform version"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch main
|
||||
|
||||
# -- STEP 4: Update version badges ----------------------------------------
|
||||
- name: "Step 4: Update version badges"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/badge_update.php --path . --version "$VERSION"
|
||||
|
||||
# -- STEP 5: Write updates.xml (Joomla update server) ---------------------
|
||||
- name: "Step 5: Write update stream"
|
||||
id: updates
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
|
||||
# Generate updates.xml with all stability channels + suffixed versions
|
||||
# Also exports ext_element, ext_name, ext_type, ext_folder to GITHUB_OUTPUT
|
||||
php $CLI/updates_xml_build.php \
|
||||
--path . \
|
||||
--version "$VERSION" \
|
||||
--stability stable \
|
||||
--gitea-url "${GITEA_URL}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}" \
|
||||
--github-output
|
||||
|
||||
echo "updates.xml: ${VERSION} (all channels updated to stable)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Commit all changes ---------------------------------------------------
|
||||
- name: Commit release changes
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
if git diff --quiet && git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
# Set push URL with token for branch-protected repos
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git commit -m "chore(release): build ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push -u origin HEAD
|
||||
|
||||
# -- STEP 6: Create tag ---------------------------------------------------
|
||||
- name: "Step 6: Create git tag"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.tag_exists != 'true' &&
|
||||
steps.version.outputs.is_minor == 'true'
|
||||
run: |
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
# Only create the major release tag if it doesn't exist yet
|
||||
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
git tag "$RELEASE_TAG"
|
||||
git push origin "$RELEASE_TAG"
|
||||
echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 7: Create or update Gitea Release --------------------------------
|
||||
- name: "Step 7: Gitea Release"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Get the stable release info (version and ID)
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
|
||||
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
|
||||
VERSION=$(python3 -c "
|
||||
import json, sys, re
|
||||
r = json.load(sys.stdin)
|
||||
name = r.get('name', '')
|
||||
m = re.search(r'(\d+\.\d+\.\d+)', name)
|
||||
print(m.group(1) if m else '')
|
||||
" <<< "$RELEASE_JSON" 2>/dev/null || true)
|
||||
# Reuse metadata from Step 5
|
||||
EXT_NAME="${{ steps.updates.outputs.ext_name }}"
|
||||
TYPE_PREFIX="${{ steps.updates.outputs.type_prefix }}"
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
|
||||
# Extract [Unreleased] section from changelog
|
||||
NOTES=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
|
||||
fi
|
||||
[ -z "$NOTES" ] && NOTES="Stable release"
|
||||
RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
|
||||
NOTES=$(php $CLI/release_notes.php --path . --version "$VERSION" 2>/dev/null)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
|
||||
# Update release body via API
|
||||
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 ${TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
urllib.request.urlopen(req)
|
||||
" <<< "$NOTES"
|
||||
echo "Release notes updated from CHANGELOG.md"
|
||||
php $CLI/release_manage.php \
|
||||
--action create \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--name "$RELEASE_NAME" \
|
||||
--body "## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}" \
|
||||
--target "$BRANCH" \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "$API_BASE"
|
||||
|
||||
echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 8: Build package, upload, and update checksums -------------------
|
||||
- name: "Step 8: Build package and upload"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
CLI="/tmp/moko-platform-api/cli"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Build ZIP + tar.gz via CLI (handles single and multi-extension packages)
|
||||
php $CLI/package_build.php --path . --version "$VERSION" --output-dir /tmp --github-output
|
||||
|
||||
# Read outputs from package_build
|
||||
ZIP_NAME="${{ steps.updates.outputs.type_prefix }}${{ steps.updates.outputs.ext_element }}-${VERSION}.zip"
|
||||
TAR_NAME="${{ steps.updates.outputs.type_prefix }}${{ steps.updates.outputs.ext_element }}-${VERSION}.tar.gz"
|
||||
|
||||
# Upload assets to release (handles dedup automatically)
|
||||
php $CLI/release_manage.php \
|
||||
--action upload \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--files "/tmp/${ZIP_NAME},/tmp/${TAR_NAME}" \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "$API_BASE"
|
||||
|
||||
# Regenerate updates.xml with SHA-256 from built package
|
||||
SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
php $CLI/updates_xml_build.php \
|
||||
--path . \
|
||||
--version "$VERSION" \
|
||||
--stability stable \
|
||||
--sha "$SHA256_ZIP" \
|
||||
--gitea-url "${GITEA_URL}" \
|
||||
--org "${GITEA_ORG}" \
|
||||
--repo "${GITEA_REPO}"
|
||||
|
||||
# Commit updated updates.xml
|
||||
git add updates.xml
|
||||
git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" || true
|
||||
git push || true
|
||||
|
||||
# Sync updates.xml to main via API (may be on version/XX branch)
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
|
||||
if [ -n "$FILE_SHA" ]; then
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/contents/updates.xml" \
|
||||
-d "$(jq -n \
|
||||
--arg content "$CONTENT" \
|
||||
--arg sha "$FILE_SHA" \
|
||||
--arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
|
||||
--arg branch "main" \
|
||||
'{content: $content, sha: $sha, message: $msg, branch: $branch}'
|
||||
)" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main via API" \
|
||||
|| echo "WARNING: failed to sync updates.xml to main"
|
||||
fi
|
||||
|
||||
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
|
||||
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
python3 -c "
|
||||
import sys
|
||||
version, date = sys.argv[1], sys.argv[2]
|
||||
content = open('CHANGELOG.md').read()
|
||||
old = '## [Unreleased]'
|
||||
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
|
||||
content = content.replace(old, new, 1)
|
||||
open('CHANGELOG.md', 'w').write(content)
|
||||
" "$VERSION" "$DATE"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
|
||||
git push origin main || true
|
||||
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
|
||||
fi
|
||||
# Build release body with changelog + SHA
|
||||
NOTES=$(php $CLI/release_notes.php --path . --version "$VERSION" 2>/dev/null)
|
||||
SHA256_TAR=""
|
||||
[ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n${NOTES}\n\n---\n\n### Checksums\n\n"
|
||||
BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
|
||||
BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
|
||||
[ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
|
||||
|
||||
printf '%b' "$BODY" > /tmp/release_body.md
|
||||
php $CLI/release_manage.php \
|
||||
--action update-body \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--body-file /tmp/release_body.md \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "$API_BASE"
|
||||
|
||||
echo "### Packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | SHA-256 |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---------|---------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
steps.version.outputs.stability == 'stable' &&
|
||||
secrets.GH_TOKEN != ''
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
MAJOR="${{ steps.version.outputs.major }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/release_mirror.php \
|
||||
--version "$VERSION" --tag "$RELEASE_TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
|
||||
--branch main 2>&1 || true
|
||||
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
echo "$NOTES" > /tmp/release_notes.md
|
||||
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
|
||||
|
||||
if [ -z "$EXISTING" ]; then
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--repo "$GH_REPO" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" \
|
||||
--notes-file /tmp/release_notes.md \
|
||||
--target "$BRANCH" || true
|
||||
else
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--repo "$GH_REPO" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" || true
|
||||
fi
|
||||
|
||||
# Upload assets to GitHub mirror
|
||||
for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
|
||||
if [ -f "$PKG" ]; then
|
||||
_RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
|
||||
[ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_MIRROR_TOKEN != ''
|
||||
secrets.GH_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
- name: "Step 11: Delete rc branch and recreate dev from main"
|
||||
# -- Clean up lesser pre-releases (cascade) ---------------------------------
|
||||
- name: "Delete lesser pre-release channels"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_cascade.php \
|
||||
--stability stable \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# -- STEP 11: Reset dev branch from main ------------------------------------
|
||||
- name: "Step 11: Delete and recreate dev branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
|
||||
# Delete rc branch (ephemeral — created by promote-rc)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/rc" 2>/dev/null \
|
||||
&& echo "Deleted rc branch" || echo "rc branch not found"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
@@ -409,37 +584,30 @@ jobs:
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: "Step 12: Create version branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
BRANCH_NAME="version/${VERSION}"
|
||||
MAIN_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Delete old version branch if it exists (same version re-release)
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
|
||||
|
||||
# Create version/XX.YY.ZZ from main
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
|
||||
|
||||
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Post-release: Reset dev version"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
- name: "Dolibarr: Reset dev version"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.platform.outputs.platform == 'dolibarr' &&
|
||||
steps.platform.outputs.mod_file != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
php ${MOKO_CLI}/version_reset_dev.php \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
|
||||
--branch dev --path . 2>&1 || true
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
ENCODED_PATH=$(echo "$MOD_FILE" | sed 's|^\./||' | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))")
|
||||
FILE_RESP=$(curl -sf -H "Authorization: token ${TOKEN}" "${API_BASE}/contents/${ENCODED_PATH}?ref=dev" 2>/dev/null || true)
|
||||
FILE_SHA=$(echo "$FILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
FILE_CONTENT=$(echo "$FILE_RESP" | python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin).get('content','')).decode())" 2>/dev/null || true)
|
||||
if [ -n "$FILE_SHA" ] && [ -n "$FILE_CONTENT" ]; then
|
||||
UPDATED=$(echo "$FILE_CONTENT" | sed "s/\$this->version = '[^']*'/\$this->version = 'development'/")
|
||||
ENCODED=$(echo "$UPDATED" | base64 -w0)
|
||||
curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/contents/${ENCODED_PATH}" \
|
||||
-d "$(jq -n --arg content \"$ENCODED\" --arg sha \"$FILE_SHA\" --arg msg \"chore(version): reset dev version [skip ci]\" --arg branch \"dev\" '{content:$content,sha:$sha,message:$msg,branch:$branch}')" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: "Publish to Composer"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '[0-9]*.[0-9]*.[0-9]*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Package
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip publish]')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-dev --no-interaction --prefer-dist --quiet
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Package version: ${VERSION}"
|
||||
|
||||
# Gitea Composer Registry — auto-publishes from tags
|
||||
# The tag push itself registers the package at:
|
||||
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
|
||||
- name: Verify Gitea registry
|
||||
run: |
|
||||
echo "Gitea Composer registry auto-publishes from tags."
|
||||
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
|
||||
echo "Install: composer require mokoconsulting/mokocli"
|
||||
|
||||
# Packagist — notify of new version
|
||||
- name: Notify Packagist
|
||||
if: secrets.PACKAGIST_TOKEN != ''
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Notifying Packagist of version ${VERSION}..."
|
||||
curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
|
||||
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
|
||||
&& echo "Packagist notified" \
|
||||
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -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:
|
||||
|
||||
@@ -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.04.08
|
||||
# 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
|
||||
@@ -18,6 +18,7 @@ on:
|
||||
- "Joomla Build & Release"
|
||||
- "Joomla Extension CI"
|
||||
- "Deploy"
|
||||
- "Cascade Main → Dev"
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# INGROUP: MokoStandards.CI
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: PR gate — branch policy + code validation before merge
|
||||
|
||||
name: "Universal: PR Check"
|
||||
@@ -52,22 +52,22 @@ jobs:
|
||||
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)
|
||||
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="RC branch can only merge into 'main', not '${BASE}'"
|
||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
@@ -96,32 +96,6 @@ jobs:
|
||||
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
|
||||
@@ -131,25 +105,10 @@ jobs:
|
||||
- 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:]')
|
||||
PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -173,98 +132,6 @@ jobs:
|
||||
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 }}"
|
||||
@@ -282,13 +149,6 @@ jobs:
|
||||
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)
|
||||
@@ -321,160 +181,6 @@ jobs:
|
||||
;;
|
||||
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"
|
||||
@@ -486,49 +192,3 @@ jobs:
|
||||
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."
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
@@ -7,14 +7,18 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
|
||||
# INGROUP: MokoStandards.Validation
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/repo_health.yml.template
|
||||
# VERSION: 09.23.00
|
||||
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
|
||||
# VERSION: 04.06.00
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# ============================================================================
|
||||
|
||||
name: "Generic: Repo Health"
|
||||
name: "Joomla: Repo Health"
|
||||
|
||||
concurrency:
|
||||
group: repo-health-${{ github.repository }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -24,28 +28,32 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, scripts, or repo'
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Release policy - Repository Variables Only
|
||||
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
|
||||
# Repo health policy
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
|
||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||
REPO_DISALLOWED_DIRS:
|
||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||
@@ -56,7 +64,7 @@ env:
|
||||
# File / directory variables
|
||||
DOCS_INDEX: docs/docs-index.md
|
||||
SCRIPT_DIR: scripts
|
||||
WORKFLOWS_DIR: .mokogitea/workflows
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
SHELLCHECK_PATTERN: '*.sh'
|
||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -77,7 +85,7 @@ jobs:
|
||||
- name: Check actor permission (admin only)
|
||||
id: perm
|
||||
env:
|
||||
TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||
TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
@@ -134,6 +142,101 @@ jobs:
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
release_config:
|
||||
name: Release configuration
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guardrails release vars
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes release validation'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
|
||||
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
|
||||
|
||||
missing=()
|
||||
missing_optional=()
|
||||
|
||||
for k in "${required[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing+=("${k}")
|
||||
done
|
||||
|
||||
for k in "${optional[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing_optional+=("${k}")
|
||||
done
|
||||
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Variable | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
|
||||
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repository variables'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repository variables'
|
||||
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository variables validation result'
|
||||
printf '%s\n' 'Status: OK'
|
||||
printf '%s\n' 'All required repository variables present.'
|
||||
printf '%s\n' ''
|
||||
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
@@ -157,14 +260,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|scripts|repo) ;;
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'repo' ]; then
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -185,7 +288,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi
|
||||
IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
|
||||
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
|
||||
|
||||
missing_dirs=()
|
||||
@@ -271,14 +374,14 @@ jobs:
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|scripts|repo) ;;
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ]; then
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
@@ -289,27 +392,23 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid for extension repos)
|
||||
SOURCE_DIR=""
|
||||
# Source directory: src/ or htdocs/ (either is valid)
|
||||
if [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then
|
||||
# Platform/tooling repos don't need src/
|
||||
SOURCE_DIR=""
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
if printf '%s' "${item}" | grep -q '/$'; then
|
||||
d="${item%/}"
|
||||
@@ -351,8 +450,12 @@ jobs:
|
||||
fi
|
||||
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
||||
|
||||
if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev or dev/* branch")
|
||||
if [ "${#dev_paths[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev/* branch (e.g. dev/01.00.00)")
|
||||
fi
|
||||
|
||||
if [ "${#dev_branches[@]}" -gt 0 ]; then
|
||||
missing_required+=("invalid branch dev (must be dev/<version>)")
|
||||
fi
|
||||
|
||||
content_warnings=()
|
||||
@@ -378,7 +481,26 @@ jobs:
|
||||
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
|
||||
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
|
||||
|
||||
report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}")
|
||||
report_json="$(python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
profile = os.environ.get('PROFILE_RAW') or 'all'
|
||||
|
||||
missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
|
||||
missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
|
||||
content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
|
||||
|
||||
out = {
|
||||
'profile': profile,
|
||||
'missing_required': [x for x in missing_required if x],
|
||||
'missing_optional': [x for x in missing_optional if x],
|
||||
'content_warnings': [x for x in content_warnings if x],
|
||||
}
|
||||
|
||||
print(json.dumps(out, indent=2))
|
||||
PY
|
||||
)"
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
@@ -456,14 +578,12 @@ jobs:
|
||||
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
|
||||
fi
|
||||
|
||||
if [ -n "${SOURCE_DIR}" ]; then
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#joomla_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
@@ -509,29 +629,43 @@ jobs:
|
||||
fi
|
||||
|
||||
if [ -f "${DOCS_INDEX}" ]; then
|
||||
missing_links=""
|
||||
while IFS= read -r docline; do
|
||||
for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do
|
||||
case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac
|
||||
linkpath="${link%%#*}"
|
||||
linkpath="${linkpath%%\?*}"
|
||||
[ -z "$linkpath" ] && continue
|
||||
if [ "${linkpath:0:1}" = "/" ]; then
|
||||
testpath="${linkpath#/}"
|
||||
else
|
||||
testpath="$(dirname "${DOCS_INDEX}")/${linkpath}"
|
||||
fi
|
||||
[ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} "
|
||||
done
|
||||
done < "${DOCS_INDEX}"
|
||||
missing_links="$(python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
|
||||
idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
|
||||
base = os.getcwd()
|
||||
|
||||
bad = []
|
||||
pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
|
||||
|
||||
with open(idx, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
for m in pat.findall(line):
|
||||
link = m.strip()
|
||||
if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
|
||||
continue
|
||||
if link.startswith('/'):
|
||||
rel = link.lstrip('/')
|
||||
else:
|
||||
rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
|
||||
rel = rel.split('#', 1)[0]
|
||||
rel = rel.split('?', 1)[0]
|
||||
if not rel:
|
||||
continue
|
||||
p = os.path.join(base, rel)
|
||||
if not os.path.exists(p):
|
||||
bad.append(rel)
|
||||
|
||||
print('\n'.join(sorted(set(bad))))
|
||||
PY
|
||||
)"
|
||||
if [ -n "${missing_links}" ]; then
|
||||
extended_findings+=("docs/docs-index.md contains broken relative links")
|
||||
{
|
||||
printf '%s\n' '### Docs index link integrity'
|
||||
printf '%s\n' 'Broken relative links:'
|
||||
for bl in ${missing_links}; do
|
||||
printf '%s\n' "- ${bl}"
|
||||
done
|
||||
while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
@@ -605,7 +739,7 @@ jobs:
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release policy | N/A | Releases handled by MokoGitea |'
|
||||
printf '%s\n' '| Release variables | OK | Repository variables validation |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
@@ -630,83 +764,3 @@ jobs:
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
|
||||
site-health:
|
||||
name: Site Health
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
|
||||
- name: Uptime check
|
||||
if: env.URLS != ''
|
||||
run: |
|
||||
echo "$URLS" > /tmp/urls.txt
|
||||
php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down"
|
||||
rm -f /tmp/urls.txt
|
||||
env:
|
||||
URLS: ${{ vars.MONITORED_URLS }}
|
||||
|
||||
- name: SSL certificate check
|
||||
if: env.DOMAINS != ''
|
||||
run: |
|
||||
echo "$DOMAINS" > /tmp/domains.txt
|
||||
php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon"
|
||||
rm -f /tmp/domains.txt
|
||||
env:
|
||||
DOMAINS: ${{ vars.MONITORED_DOMAINS }}
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### Site Health" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Issue Reporter — file issues for failed gates
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
report-issues:
|
||||
name: "Report Issues"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [access_check, scripts_governance, repo_health]
|
||||
if: >-
|
||||
always() &&
|
||||
(needs.scripts_governance.result == 'failure' ||
|
||||
needs.repo_health.result == 'failure')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: automation/ci-issue-reporter.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: "File issues for failed gates"
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
run: |
|
||||
chmod +x automation/ci-issue-reporter.sh
|
||||
REPORTER="./automation/ci-issue-reporter.sh"
|
||||
WF="Repo Health"
|
||||
|
||||
report_gate() {
|
||||
local gate="$1" result="$2" details="$3"
|
||||
if [ "$result" = "failure" ]; then
|
||||
"$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error
|
||||
fi
|
||||
}
|
||||
|
||||
report_gate "Scripts Governance" \
|
||||
"${{ needs.scripts_governance.result }}" \
|
||||
"Scripts directory policy violations detected. Review required and allowed directories."
|
||||
|
||||
report_gate "Repository Health" \
|
||||
"${{ needs.repo_health.result }}" \
|
||||
"Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary."
|
||||
|
||||
@@ -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
|
||||
@@ -4,18 +4,20 @@
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Universal
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/update-server.yml
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Pre-release build + update server XML for dev/alpha/beta/rc branches
|
||||
# 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
|
||||
#
|
||||
# Thin wrapper around moko-platform CLI tools.
|
||||
# Builds packages, updates updates.xml, and optionally deploys via SFTP.
|
||||
# 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 update entries by the user's "Minimum Stability" setting.
|
||||
# Joomla filters by user's "Minimum Stability" setting.
|
||||
|
||||
name: "Update Server"
|
||||
name: "Joomla: Update Server"
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -64,60 +66,55 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-xml:
|
||||
name: Update Server
|
||||
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@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.MOKOGITEA_TOKEN }}"}}}'
|
||||
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
|
||||
# Always fetch latest CLI tools — never use stale cache from previous runs
|
||||
rm -rf /tmp/moko-platform
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform 2>/dev/null || true
|
||||
if [ -d "/tmp/moko-platform" ] && [ -f "/tmp/moko-platform/composer.json" ]; then
|
||||
cd /tmp/moko-platform && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
"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
|
||||
echo "MOKO_CLI=/tmp/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: php ${MOKO_CLI}/manifest_read.php --path . --github-output
|
||||
|
||||
- name: Resolve stability and bump version
|
||||
id: meta
|
||||
- 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")
|
||||
|
||||
# Configure git for bot pushes
|
||||
# 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]"
|
||||
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
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
|
||||
|
||||
# Auto-bump patch version
|
||||
php ${MOKO_CLI}/version_bump.php --path . 2>/dev/null || true
|
||||
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# Strip any existing suffix before applying stability
|
||||
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
|
||||
|
||||
# Determine stability from branch or manual input
|
||||
# Determine stability from branch or input
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
elif [[ "$BRANCH" == rc/* ]]; then
|
||||
@@ -126,122 +123,277 @@ jobs:
|
||||
STABILITY="beta"
|
||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
||||
STABILITY="alpha"
|
||||
else
|
||||
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
|
||||
STABILITY="development"
|
||||
else
|
||||
STABILITY="stable"
|
||||
fi
|
||||
|
||||
# Version suffix per stability stream
|
||||
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) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
rc) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
*) SUFFIX=""; TAG="stable" ;;
|
||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
||||
esac
|
||||
|
||||
# Propagate version with stability suffix to all manifest files
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "$BRANCH" --stability "$STABILITY" 2>/dev/null || true
|
||||
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
|
||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
||||
|
||||
# Re-read version (now includes suffix from version_set_platform)
|
||||
if [ -n "$SUFFIX" ]; then
|
||||
VERSION="${VERSION}${SUFFIX}"
|
||||
# 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
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "display_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
# -- 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>"
|
||||
|
||||
# Commit version bump if changed
|
||||
git add -A
|
||||
# -- 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(version): auto-bump ${VERSION} [skip ci]" \
|
||||
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push
|
||||
}
|
||||
|
||||
- name: Create release and upload package
|
||||
id: package
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Create or update Gitea release
|
||||
php ${MOKO_CLI}/release_create.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
|
||||
|
||||
# Build package and upload
|
||||
php ${MOKO_CLI}/release_package.php \
|
||||
--path . --version "$VERSION" --tag "$TAG" \
|
||||
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
|
||||
--repo "${GITEA_REPO}" --output /tmp || true
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.package.outputs.sha256_zip }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHA_FLAG=""
|
||||
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
|
||||
|
||||
php ${MOKO_CLI}/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability "${STABILITY}" \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
${SHA_FLAG}
|
||||
|
||||
# Commit and push updates.xml
|
||||
git add updates.xml
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push
|
||||
}
|
||||
|
||||
# -- Sync updates.xml to main (for non-main branches) ----------------------
|
||||
- name: Sync updates.xml to main
|
||||
if: github.ref_name != 'main' && steps.platform.outputs.platform == 'joomla'
|
||||
if: github.ref_name != 'main'
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
GITEA_TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GITEA_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
|
||||
python3 -c "
|
||||
import base64, json, urllib.request, sys
|
||||
with open('updates.xml', 'rb') as f:
|
||||
content = base64.b64encode(f.read()).decode()
|
||||
payload = json.dumps({
|
||||
'content': content,
|
||||
'sha': '${FILE_SHA}',
|
||||
'message': 'chore: sync updates.xml from ${{ steps.meta.outputs.stability }} [skip ci]',
|
||||
'branch': 'main'
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/contents/updates.xml',
|
||||
data=payload, method='PUT',
|
||||
headers={
|
||||
'Authorization': 'token ${GITEA_TOKEN}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
print('updates.xml synced to main')
|
||||
except Exception as e:
|
||||
print(f'WARNING: sync to main failed: {e}', file=sys.stderr)
|
||||
"
|
||||
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
|
||||
@@ -255,11 +407,12 @@ jobs:
|
||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
run: |
|
||||
# Permission check: admin or maintain role required
|
||||
# -- 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.MOKOGITEA_TOKEN }}" \
|
||||
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
|
||||
@@ -289,11 +442,11 @@ jobs:
|
||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
PLATFORM=$(php ${MOKO_CLI}/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "${MOKO_CLI}/../deploy/deploy-joomla.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
elif [ -f "${MOKO_CLI}/../deploy/deploy-sftp.php" ]; then
|
||||
php ${MOKO_CLI}/../deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
||||
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
|
||||
@@ -301,12 +454,11 @@ jobs:
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
DISPLAY="${{ steps.meta.outputs.display_version }}"
|
||||
echo "## Update Server" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 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}\` |" >> $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
|
||||
|
||||
@@ -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}
|
||||
+11
-76
@@ -1,86 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
<!-- VERSION: 01.04.08 -->
|
||||
<!-- VERSION: 01.00.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/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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)
|
||||
|
||||
### 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)
|
||||
- FAQ JSON-LD schema with auto-detection from article h3/h4 headings (#62)
|
||||
- HowTo JSON-LD schema with auto-detection from ordered lists (#63)
|
||||
- Event JSON-LD schema with per-article event fields (dates, venue, tickets) (#64)
|
||||
- LocalBusiness JSON-LD schema with global plugin configuration (#65)
|
||||
- Recipe JSON-LD schema with per-article fields (times, ingredients, nutrition) (#66)
|
||||
- VideoObject JSON-LD schema for articles with video URLs (#67)
|
||||
- SEO content scoring panel with 7 checks and pass/fail indicators (#68)
|
||||
- Discord, Mastodon, and Slack social preview cards in editor (#69)
|
||||
- Custom JSON-LD schema builder — per-article textarea for any schema.org type (#70)
|
||||
- AI-powered meta tag generation with Claude and OpenAI API support (#71)
|
||||
- XML sitemap generation on article save, respects noindex directives (#72)
|
||||
- OG coverage dashboard in tag manager with coverage percentage (#73)
|
||||
- Per-platform image resizing: Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400 (#74)
|
||||
- PHPUnit test suite with 16 unit tests for JsonLdBuilder (#75)
|
||||
- OpenAPI 3.0 specification for REST API (#80)
|
||||
- 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
|
||||
|
||||
### Fixed
|
||||
- Add exception logging to BatchController batch skip (#76)
|
||||
- Align form maxlength attributes with DB schema limits (#77)
|
||||
- Add `strip_tags()` input sanitization on OG text fields (#79)
|
||||
- Only emit `og:video:secure_url` for HTTPS URLs
|
||||
- Only emit `og:video:width/height` for direct files, not embeds
|
||||
- Consolidate duplicate MokoSuiteShop product blocks
|
||||
- Fix stale `com_virtuemart` reference in SQL comment
|
||||
- Use component language keys for og_video field in tag.xml
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,85 +1,40 @@
|
||||
# MokoSuiteOpenGraph
|
||||
# MokoOpenGraph
|
||||
|
||||
<!-- VERSION: 01.04.08 -->
|
||||
<!-- 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
|
||||
- **Mastodon/Fediverse** — `fediverse:creator` for author attribution (first extension on any CMS)
|
||||
- **Pinterest** — Rich pin tags: `article:tag`, `product:availability`, `product:price`
|
||||
- **og:video** — Per-article video URLs with auto MIME type detection (YouTube/Vimeo/direct)
|
||||
- **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, FAQ, HowTo, Event, Recipe, LocalBusiness, VideoObject, and custom schemas
|
||||
- **SEO content scoring** — 7-check analysis panel with pass/fail indicators in the editor
|
||||
|
||||
### 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, Twitter/X, LinkedIn, Discord, Mastodon, and Slack card previews in the editor
|
||||
- **Character count indicators** — Green/yellow/red warnings on OG and SEO text fields
|
||||
- **OG coverage dashboard** — Coverage percentage and missing field counts
|
||||
- **AI meta generation** — Generate OG titles and descriptions with Claude or OpenAI
|
||||
|
||||
### Developer Features
|
||||
- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`)
|
||||
- **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta
|
||||
- **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags
|
||||
- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630
|
||||
- **Per-platform image resizing** — Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400
|
||||
- **XML sitemap** — Auto-generates sitemap.xml on article save, respects noindex
|
||||
- **OpenAPI spec** — Full REST API documentation at `openapi.yaml`
|
||||
- **PHPUnit tests** — 16 unit tests for JsonLdBuilder schema outputs
|
||||
- **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
|
||||
- Fediverse/Mastodon creator handle
|
||||
- LocalBusiness schema (address, phone, hours, geo)
|
||||
- XML sitemap generation
|
||||
- AI meta generation (Claude/OpenAI API key)
|
||||
- Per-platform image resizing
|
||||
- Auto-generation, image resize, JSON-LD, and description length settings
|
||||
- Auto-generation behavior
|
||||
- Description length limit
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+2
-18
@@ -15,26 +15,10 @@
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"joomla/coding-standards": "^3.0",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
"joomla/coding-standards": "^4.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Joomla\\Plugin\\System\\MokoOG\\": "source/packages/plg_system_mokoog/src/",
|
||||
"Joomla\\Plugin\\Content\\MokoOG\\": "source/packages/plg_content_mokoog/src/",
|
||||
"Joomla\\Plugin\\WebServices\\MokoOG\\": "source/packages/plg_webservices_mokoog/src/",
|
||||
"Joomla\\Component\\MokoOG\\Administrator\\": "source/packages/com_mokoog/src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Mokoconsulting\\MokoOG\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "alpha",
|
||||
"prefer-stable": true,
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
}
|
||||
|
||||
-668
@@ -1,668 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
|
||||
info:
|
||||
title: MokoSuiteOpenGraph API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
REST API for managing Open Graph, SEO meta, and structured-data tags in
|
||||
Joomla via the MokoSuiteOpenGraph extension.
|
||||
|
||||
The API follows Joomla's Web Services conventions and returns responses in
|
||||
[JSON:API](https://jsonapi.org/) format. All endpoints require
|
||||
authentication via a Joomla API token.
|
||||
contact:
|
||||
name: Moko Consulting
|
||||
email: hello@mokoconsulting.tech
|
||||
license:
|
||||
name: GPL-3.0-or-later
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
servers:
|
||||
- url: /api/index.php/v1
|
||||
description: Joomla Web Services API
|
||||
|
||||
security:
|
||||
- apiToken: []
|
||||
|
||||
tags:
|
||||
- name: Tags
|
||||
description: CRUD operations for Open Graph tag records
|
||||
|
||||
paths:
|
||||
/mokoog/tags:
|
||||
get:
|
||||
operationId: listTags
|
||||
summary: List OG tags
|
||||
description: |
|
||||
Returns a paginated collection of OG tag records. Supports filtering
|
||||
by content type, published state, and language.
|
||||
tags: [Tags]
|
||||
parameters:
|
||||
- name: "filter[content_type]"
|
||||
in: query
|
||||
description: Filter by content type (e.g. `com_content`, `menu`, `com_mokoshop`)
|
||||
schema:
|
||||
type: string
|
||||
example: com_content
|
||||
- name: "filter[content_id]"
|
||||
in: query
|
||||
description: Filter by content ID
|
||||
schema:
|
||||
type: integer
|
||||
example: 42
|
||||
- name: "filter[published]"
|
||||
in: query
|
||||
description: Filter by published state
|
||||
schema:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
- name: "filter[language]"
|
||||
in: query
|
||||
description: Filter by language tag (e.g. `en-GB`, `*`)
|
||||
schema:
|
||||
type: string
|
||||
example: "*"
|
||||
- name: "filter[search]"
|
||||
in: query
|
||||
description: Free-text search across tag fields
|
||||
schema:
|
||||
type: string
|
||||
- name: "page[offset]"
|
||||
in: query
|
||||
description: Number of records to skip (pagination offset)
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
- name: "page[limit]"
|
||||
in: query
|
||||
description: Maximum number of records to return
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 25
|
||||
- name: "list[fullordering]"
|
||||
in: query
|
||||
description: Sort order for results
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- a.id ASC
|
||||
- a.id DESC
|
||||
- a.og_title ASC
|
||||
- a.og_title DESC
|
||||
- a.modified ASC
|
||||
- a.modified DESC
|
||||
default: a.modified DESC
|
||||
responses:
|
||||
"200":
|
||||
description: A JSON:API collection of OG tags
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagCollection"
|
||||
example:
|
||||
links:
|
||||
self: "/api/index.php/v1/mokoog/tags"
|
||||
data:
|
||||
- type: tags
|
||||
id: "1"
|
||||
attributes:
|
||||
content_type: com_content
|
||||
content_id: 42
|
||||
og_title: "My Article Title"
|
||||
og_description: "A brief description for social sharing."
|
||||
og_image: "images/mokoog/og-banner.jpg"
|
||||
og_type: article
|
||||
seo_title: "My Article | Example Site"
|
||||
meta_description: "A brief meta description for search engines."
|
||||
robots: "index, follow"
|
||||
canonical_url: "https://example.com/my-article"
|
||||
language: "*"
|
||||
published: 1
|
||||
created: "2026-06-01T12:00:00+00:00"
|
||||
modified: "2026-06-15T08:30:00+00:00"
|
||||
meta:
|
||||
total-pages: 1
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
post:
|
||||
operationId: createTag
|
||||
summary: Create an OG tag
|
||||
description: |
|
||||
Creates a new OG tag record. The combination of `content_type`,
|
||||
`content_id`, and `language` must be unique.
|
||||
tags: [Tags]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagCreateRequest"
|
||||
example:
|
||||
content_type: com_content
|
||||
content_id: 42
|
||||
og_title: "My Article Title"
|
||||
og_description: "A brief description for social sharing."
|
||||
og_image: "images/mokoog/og-banner.jpg"
|
||||
og_type: article
|
||||
language: "*"
|
||||
published: 1
|
||||
responses:
|
||||
"200":
|
||||
description: The created tag
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagDocument"
|
||||
example:
|
||||
links:
|
||||
self: "/api/index.php/v1/mokoog/tags/1"
|
||||
data:
|
||||
type: tags
|
||||
id: "1"
|
||||
attributes:
|
||||
content_type: com_content
|
||||
content_id: 42
|
||||
og_title: "My Article Title"
|
||||
og_description: "A brief description for social sharing."
|
||||
og_image: "images/mokoog/og-banner.jpg"
|
||||
og_type: article
|
||||
seo_title: ""
|
||||
meta_description: ""
|
||||
robots: ""
|
||||
canonical_url: ""
|
||||
language: "*"
|
||||
published: 1
|
||||
created: "2026-06-23T10:00:00+00:00"
|
||||
modified: "2026-06-23T10:00:00+00:00"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
/mokoog/tags/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TagId"
|
||||
|
||||
get:
|
||||
operationId: getTag
|
||||
summary: Get a single OG tag
|
||||
tags: [Tags]
|
||||
responses:
|
||||
"200":
|
||||
description: A single OG tag resource
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagDocument"
|
||||
example:
|
||||
links:
|
||||
self: "/api/index.php/v1/mokoog/tags/1"
|
||||
data:
|
||||
type: tags
|
||||
id: "1"
|
||||
attributes:
|
||||
content_type: com_content
|
||||
content_id: 42
|
||||
og_title: "My Article Title"
|
||||
og_description: "A brief description for social sharing."
|
||||
og_image: "images/mokoog/og-banner.jpg"
|
||||
og_type: article
|
||||
seo_title: "My Article | Example Site"
|
||||
meta_description: "A brief meta description for search engines."
|
||||
robots: "index, follow"
|
||||
canonical_url: "https://example.com/my-article"
|
||||
language: "*"
|
||||
published: 1
|
||||
created: "2026-06-01T12:00:00+00:00"
|
||||
modified: "2026-06-15T08:30:00+00:00"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
patch:
|
||||
operationId: updateTag
|
||||
summary: Update an OG tag
|
||||
description: Partially updates an existing OG tag. Only supplied fields are changed.
|
||||
tags: [Tags]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagUpdateRequest"
|
||||
example:
|
||||
og_title: "Updated Title"
|
||||
og_description: "Updated social description."
|
||||
published: 0
|
||||
responses:
|
||||
"200":
|
||||
description: The updated tag
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagDocument"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
delete:
|
||||
operationId: deleteTag
|
||||
summary: Delete an OG tag
|
||||
tags: [Tags]
|
||||
responses:
|
||||
"204":
|
||||
description: Tag deleted successfully
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/mokoog/lookup/{content_type}/{content_id}:
|
||||
get:
|
||||
operationId: lookupTag
|
||||
summary: Look up an OG tag by content type and content ID
|
||||
description: |
|
||||
Resolves an OG tag by its `content_type` and `content_id` pair and
|
||||
returns the full tag resource. This is a convenience endpoint that
|
||||
avoids the caller needing to know the internal tag ID.
|
||||
tags: [Tags]
|
||||
parameters:
|
||||
- name: content_type
|
||||
in: path
|
||||
required: true
|
||||
description: |
|
||||
The content type identifier (e.g. `com_content`, `menu`,
|
||||
`com_mokoshop`). Must match the pattern `[a-z][a-z0-9_.]*`.
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9_.]*$"
|
||||
example: com_content
|
||||
- name: content_id
|
||||
in: path
|
||||
required: true
|
||||
description: The content item ID
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 42
|
||||
responses:
|
||||
"200":
|
||||
description: The matching OG tag resource
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagDocument"
|
||||
"400":
|
||||
description: Missing or invalid content_type / content_id
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Bad Request
|
||||
status: 400
|
||||
detail: "content_type and content_id are required"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
description: No OG tag found for the given content_type and content_id
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Not Found
|
||||
status: 404
|
||||
detail: "OG tag not found for com_content:42"
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
apiToken:
|
||||
type: apiKey
|
||||
name: X-Joomla-Token
|
||||
in: header
|
||||
description: |
|
||||
Joomla API token. Can also be passed as the `api-token` query
|
||||
parameter. Generate a token from the Joomla administrator panel
|
||||
under Users > Manage > [user] > Joomla API Token tab.
|
||||
|
||||
parameters:
|
||||
TagId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
description: The OG tag record ID
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 1
|
||||
|
||||
schemas:
|
||||
TagAttributes:
|
||||
type: object
|
||||
description: Full set of OG tag attributes returned by the API
|
||||
properties:
|
||||
content_type:
|
||||
type: string
|
||||
description: |
|
||||
Content type identifier (e.g. `com_content`, `menu`,
|
||||
`com_mokoshop`). Must match `[a-z][a-z0-9_.]*`.
|
||||
pattern: "^[a-z][a-z0-9_.]*$"
|
||||
maxLength: 100
|
||||
example: com_content
|
||||
content_id:
|
||||
type: integer
|
||||
description: The ID of the associated content item
|
||||
minimum: 1
|
||||
example: 42
|
||||
og_title:
|
||||
type: string
|
||||
description: Open Graph title (`og:title`)
|
||||
maxLength: 255
|
||||
example: "My Article Title"
|
||||
og_description:
|
||||
type: string
|
||||
description: Open Graph description (`og:description`)
|
||||
example: "A brief description for social sharing."
|
||||
og_image:
|
||||
type: string
|
||||
description: Relative path to the Open Graph image (`og:image`)
|
||||
maxLength: 512
|
||||
example: "images/mokoog/og-banner.jpg"
|
||||
og_type:
|
||||
type: string
|
||||
description: Open Graph type (`og:type`)
|
||||
default: article
|
||||
enum:
|
||||
- article
|
||||
- website
|
||||
- product
|
||||
- profile
|
||||
- book
|
||||
- music.song
|
||||
- music.album
|
||||
- video.movie
|
||||
- video.episode
|
||||
- video.other
|
||||
example: article
|
||||
seo_title:
|
||||
type: string
|
||||
description: SEO page title (used in `<title>` tag)
|
||||
maxLength: 70
|
||||
example: "My Article | Example Site"
|
||||
meta_description:
|
||||
type: string
|
||||
description: Meta description for search engines
|
||||
maxLength: 200
|
||||
example: "A brief meta description for search engines."
|
||||
robots:
|
||||
type: string
|
||||
description: |
|
||||
Comma-separated robots directives. Valid directives: `index`,
|
||||
`noindex`, `follow`, `nofollow`, `none`, `noarchive`,
|
||||
`nosnippet`, `noimageindex`, `max-snippet`, `max-image-preview`.
|
||||
maxLength: 100
|
||||
example: "index, follow"
|
||||
canonical_url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Canonical URL for the page
|
||||
maxLength: 512
|
||||
example: "https://example.com/my-article"
|
||||
language:
|
||||
type: string
|
||||
description: Joomla language tag (`*` for all languages)
|
||||
maxLength: 7
|
||||
default: "*"
|
||||
example: "*"
|
||||
published:
|
||||
type: integer
|
||||
description: Published state (1 = published, 0 = unpublished)
|
||||
enum: [0, 1]
|
||||
default: 1
|
||||
example: 1
|
||||
created:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Record creation timestamp (read-only)
|
||||
readOnly: true
|
||||
example: "2026-06-01T12:00:00+00:00"
|
||||
modified:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Last modification timestamp (read-only)
|
||||
readOnly: true
|
||||
example: "2026-06-15T08:30:00+00:00"
|
||||
|
||||
TagResource:
|
||||
type: object
|
||||
description: A single OG tag in JSON:API resource format
|
||||
required: [type, id, attributes]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [tags]
|
||||
example: tags
|
||||
id:
|
||||
type: string
|
||||
description: The record ID as a string (per JSON:API spec)
|
||||
example: "1"
|
||||
attributes:
|
||||
$ref: "#/components/schemas/TagAttributes"
|
||||
|
||||
TagDocument:
|
||||
type: object
|
||||
description: JSON:API document containing a single tag resource
|
||||
properties:
|
||||
links:
|
||||
type: object
|
||||
properties:
|
||||
self:
|
||||
type: string
|
||||
example: "/api/index.php/v1/mokoog/tags/1"
|
||||
data:
|
||||
$ref: "#/components/schemas/TagResource"
|
||||
|
||||
TagCollection:
|
||||
type: object
|
||||
description: JSON:API document containing a collection of tag resources
|
||||
properties:
|
||||
links:
|
||||
type: object
|
||||
properties:
|
||||
self:
|
||||
type: string
|
||||
example: "/api/index.php/v1/mokoog/tags"
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TagResource"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
total-pages:
|
||||
type: integer
|
||||
description: Total number of pages available
|
||||
example: 1
|
||||
|
||||
TagCreateRequest:
|
||||
type: object
|
||||
description: Request body for creating a new OG tag
|
||||
required:
|
||||
- content_type
|
||||
- content_id
|
||||
properties:
|
||||
content_type:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9_.]*$"
|
||||
maxLength: 100
|
||||
example: com_content
|
||||
content_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 42
|
||||
og_title:
|
||||
type: string
|
||||
maxLength: 255
|
||||
og_description:
|
||||
type: string
|
||||
og_image:
|
||||
type: string
|
||||
maxLength: 512
|
||||
og_type:
|
||||
type: string
|
||||
default: article
|
||||
enum:
|
||||
- article
|
||||
- website
|
||||
- product
|
||||
- profile
|
||||
- book
|
||||
- music.song
|
||||
- music.album
|
||||
- video.movie
|
||||
- video.episode
|
||||
- video.other
|
||||
og_video:
|
||||
type: string
|
||||
format: uri
|
||||
description: Open Graph video URL (`og:video`)
|
||||
maxLength: 512
|
||||
seo_title:
|
||||
type: string
|
||||
maxLength: 70
|
||||
meta_description:
|
||||
type: string
|
||||
maxLength: 200
|
||||
robots:
|
||||
type: string
|
||||
maxLength: 100
|
||||
canonical_url:
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 512
|
||||
language:
|
||||
type: string
|
||||
maxLength: 7
|
||||
default: "*"
|
||||
published:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
default: 1
|
||||
|
||||
TagUpdateRequest:
|
||||
type: object
|
||||
description: |
|
||||
Request body for updating an OG tag. All fields are optional; only
|
||||
supplied fields are modified.
|
||||
properties:
|
||||
og_title:
|
||||
type: string
|
||||
maxLength: 255
|
||||
og_description:
|
||||
type: string
|
||||
og_image:
|
||||
type: string
|
||||
maxLength: 512
|
||||
og_type:
|
||||
type: string
|
||||
enum:
|
||||
- article
|
||||
- website
|
||||
- product
|
||||
- profile
|
||||
- book
|
||||
- music.song
|
||||
- music.album
|
||||
- video.movie
|
||||
- video.episode
|
||||
- video.other
|
||||
og_video:
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 512
|
||||
seo_title:
|
||||
type: string
|
||||
maxLength: 70
|
||||
meta_description:
|
||||
type: string
|
||||
maxLength: 200
|
||||
robots:
|
||||
type: string
|
||||
maxLength: 100
|
||||
canonical_url:
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 512
|
||||
language:
|
||||
type: string
|
||||
maxLength: 7
|
||||
published:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
description: JSON:API error response
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
example: Not Found
|
||||
status:
|
||||
type: integer
|
||||
example: 404
|
||||
detail:
|
||||
type: string
|
||||
example: "Item not found."
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Invalid request data
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Bad Request
|
||||
status: 400
|
||||
detail: "Content type is required."
|
||||
|
||||
Unauthorized:
|
||||
description: Missing or invalid API token
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Forbidden
|
||||
status: 403
|
||||
detail: "You are not authorised to access this resource."
|
||||
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Not Found
|
||||
status: 404
|
||||
detail: "Item not found."
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>source/packages</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -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 +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,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,6 +0,0 @@
|
||||
--
|
||||
-- MokoJoomOpenGraph 01.04.00 - Add event_data and recipe_data columns
|
||||
--
|
||||
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `event_data` TEXT NULL AFTER `og_video`;
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `recipe_data` TEXT NULL AFTER `event_data`;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `custom_schema` TEXT NULL AFTER `canonical_url`;
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -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 +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,58 +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
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Total published articles
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__content')->where('state = 1'));
|
||||
$totalArticles = (int) $db->loadResult();
|
||||
|
||||
// Articles with OG tags
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(DISTINCT content_id)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where('published = 1'));
|
||||
$articlesWithOg = (int) $db->loadResult();
|
||||
|
||||
// Articles missing OG data fields
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_title = ''")->where('published = 1'));
|
||||
$missingTitle = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_description = ''")->where('published = 1'));
|
||||
$missingDesc = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_image = ''")->where('published = 1'));
|
||||
$missingImage = (int) $db->loadResult();
|
||||
|
||||
$coverage = $totalArticles > 0 ? round(($articlesWithOg / $totalArticles) * 100) : 0;
|
||||
?>
|
||||
<div class="mokoog-coverage card mb-3">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_COVERAGE_TITLE'); ?></h4>
|
||||
<div class="row">
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="display-4 <?php echo $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger'); ?>">
|
||||
<?php echo $coverage; ?>%
|
||||
</div>
|
||||
<small class="text-muted"><?php echo Text::_('COM_MOKOOG_COVERAGE_PERCENT'); ?></small>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<ul class="list-unstyled">
|
||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_ARTICLES', $articlesWithOg, $totalArticles); ?></li>
|
||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_TITLE', $missingTitle); ?></li>
|
||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_DESC', $missingDesc); ?></li>
|
||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_IMAGE', $missingImage); ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,129 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage plg_content_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
|
||||
-->
|
||||
<form>
|
||||
<fields name="mokoog">
|
||||
<fieldset name="mokoog" label="PLG_CONTENT_MOKOOG_FIELDSET_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_DESC">
|
||||
<field
|
||||
name="og_title"
|
||||
type="text"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_OG_TITLE"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_OG_TITLE_DESC"
|
||||
filter="string"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="og_description"
|
||||
type="textarea"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION_DESC"
|
||||
filter="string"
|
||||
rows="3"
|
||||
maxlength="512"
|
||||
/>
|
||||
<field
|
||||
name="og_image"
|
||||
type="media"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC"
|
||||
directory="mokoog"
|
||||
/>
|
||||
<field
|
||||
name="og_type"
|
||||
type="list"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_OG_TYPE"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC"
|
||||
default="article"
|
||||
>
|
||||
<option value="article">Article</option>
|
||||
<option value="website">Website</option>
|
||||
<option value="product">Product</option>
|
||||
<option value="profile">Profile</option>
|
||||
<option value="book">Book</option>
|
||||
<option value="music.song">Music</option>
|
||||
<option value="video.other">Video</option>
|
||||
</field>
|
||||
<field
|
||||
name="og_video"
|
||||
type="url"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC"
|
||||
filter="url"
|
||||
validate="url"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_seo" label="PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC">
|
||||
<field
|
||||
name="seo_title"
|
||||
type="text"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
|
||||
filter="string"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="meta_description"
|
||||
type="textarea"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
|
||||
filter="string"
|
||||
rows="3"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="robots"
|
||||
type="list"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC"
|
||||
default=""
|
||||
multiple="true"
|
||||
>
|
||||
<option value="">PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT</option>
|
||||
<option value="noindex">noindex</option>
|
||||
<option value="nofollow">nofollow</option>
|
||||
<option value="nosnippet">nosnippet</option>
|
||||
<option value="noarchive">noarchive</option>
|
||||
<option value="noimageindex">noimageindex</option>
|
||||
</field>
|
||||
<field
|
||||
name="canonical_url"
|
||||
type="url"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC"
|
||||
filter="url"
|
||||
validate="url"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_event" label="PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC">
|
||||
<field name="event_start" type="calendar" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_START" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC" format="%Y-%m-%d %H:%M" filter="string" showtime="true" />
|
||||
<field name="event_end" type="calendar" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_END" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC" format="%Y-%m-%d %H:%M" filter="string" showtime="true" />
|
||||
<field name="event_location" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC" filter="string" />
|
||||
<field name="event_address" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC" filter="string" />
|
||||
<field name="event_price" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC" filter="string" />
|
||||
<field name="event_currency" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC" filter="string" default="USD" />
|
||||
<field name="event_url" type="url" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_URL" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC" filter="url" />
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_recipe" label="PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC">
|
||||
<field name="recipe_prep_time" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC" filter="string" hint="PT15M" />
|
||||
<field name="recipe_cook_time" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC" filter="string" hint="PT30M" />
|
||||
<field name="recipe_yield" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC" filter="string" hint="4 servings" />
|
||||
<field name="recipe_calories" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC" filter="string" hint="350" />
|
||||
<field name="recipe_ingredients" type="textarea" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC" filter="string" rows="5" hint="One ingredient per line" />
|
||||
<field name="recipe_category" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC" filter="string" hint="Dessert" />
|
||||
<field name="recipe_cuisine" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC" filter="string" hint="Italian" />
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_custom_schema" label="PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC">
|
||||
<field name="custom_schema" type="textarea" label="PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA" description="PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC" filter="raw" rows="12" class="input-xxlarge" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</form>
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,70 +0,0 @@
|
||||
; MokoJoomOpenGraph - Content Plugin Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_LABEL="Open Graph / Social Sharing"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_DESC="Configure how this content appears when shared on social media."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TITLE="OG Title"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing. Leave blank to use the article title."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing. Leave blank to auto-generate from content."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO="Video URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video to embed in social sharing previews. Supports direct video URLs and YouTube/Vimeo links. Outputs og:video meta tags."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title"
|
||||
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom <title> tag. 50-60 characters recommended. Leave blank to use the default page title."
|
||||
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
|
||||
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default."
|
||||
PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive"
|
||||
PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)."
|
||||
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL="Event Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC="Optional event information for JSON-LD Event schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START="Start Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC="Event start date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END="End Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC="Event end date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION="Venue Name"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC="Name of the event venue or location."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS="Venue Address"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC="Full address of the event venue."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE="Ticket Price"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC="Ticket price (e.g. 50.00). Leave blank if free."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY="Currency"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC="Currency code for ticket price (e.g. USD, EUR, GBP)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL="Ticket URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC="URL where tickets can be purchased."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL="Recipe Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC="Optional recipe information for JSON-LD Recipe schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME="Prep Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC="Preparation time in ISO 8601 duration format (e.g. PT15M for 15 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME="Cook Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC="Cooking time in ISO 8601 duration format (e.g. PT30M for 30 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD="Yield"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC="Number of servings or yield (e.g. 4 servings, 1 loaf)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES="Calories"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC="Calories per serving."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS="Ingredients"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC="One ingredient per line."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL="Custom Schema"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC="Add custom JSON-LD structured data for this page."
|
||||
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA="Custom JSON-LD"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC="Enter valid JSON-LD structured data. The @context will be added automatically if missing. Use for schema types not covered by built-in options (e.g. Course, JobPosting, SoftwareApplication)."
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,70 +0,0 @@
|
||||
; MokoJoomOpenGraph - Content Plugin Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_LABEL="Open Graph / Social Sharing"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_DESC="Configure how this content appears when shared on social media."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TITLE="OG Title"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing. Leave blank to use the article title."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION="OG Description"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing. Leave blank to auto-generate from content."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE="OG Image"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recommended: 1200x630px. Leave blank to use the article image."
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO="Video URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video to embed in social sharing previews. Supports direct video URLs and YouTube/Vimeo links. Outputs og:video meta tags."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE="SEO Title"
|
||||
PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC="Custom <title> tag. 50-60 characters recommended. Leave blank to use the default page title."
|
||||
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION="Meta Description"
|
||||
PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC="Custom meta description. 150-160 characters recommended. Leave blank to use the default."
|
||||
PLG_CONTENT_MOKOOG_FIELD_ROBOTS="Robots Directive"
|
||||
PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this page. Leave blank for default (index, follow)."
|
||||
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL="Event Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC="Optional event information for JSON-LD Event schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START="Start Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC="Event start date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END="End Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC="Event end date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION="Venue Name"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC="Name of the event venue or location."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS="Venue Address"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC="Full address of the event venue."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE="Ticket Price"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC="Ticket price (e.g. 50.00). Leave blank if free."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY="Currency"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC="Currency code for ticket price (e.g. USD, EUR, GBP)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL="Ticket URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC="URL where tickets can be purchased."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL="Recipe Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC="Optional recipe information for JSON-LD Recipe schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME="Prep Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC="Preparation time in ISO 8601 duration format (e.g. PT15M for 15 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME="Cook Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC="Cooking time in ISO 8601 duration format (e.g. PT30M for 30 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD="Yield"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC="Number of servings or yield (e.g. 4 servings, 1 loaf)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES="Calories"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC="Calories per serving."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS="Ingredients"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC="One ingredient per line."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL="Custom Schema"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC="Add custom JSON-LD structured data for this page."
|
||||
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA="Custom JSON-LD"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC="Enter valid JSON-LD structured data. The @context will be added automatically if missing. Use for schema types not covered by built-in options (e.g. Course, JobPosting, SoftwareApplication)."
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,255 +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;
|
||||
}
|
||||
|
||||
/* Discord card */
|
||||
.mokoog-card-dc {
|
||||
background: #2b2d31;
|
||||
border-left: 4px solid #5865f2;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-body {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-img {
|
||||
height: 200px;
|
||||
margin: 0 12px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #00a8fc;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-desc {
|
||||
font-size: 14px;
|
||||
color: #dbdee1;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-domain {
|
||||
font-size: 12px;
|
||||
color: #b5bac1;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Mastodon card */
|
||||
.mokoog-card-ma {
|
||||
border: 1px solid #c8ccd0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-img {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-body {
|
||||
border-top-color: #c8ccd0;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-desc {
|
||||
font-size: 13px;
|
||||
color: #606984;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-domain {
|
||||
font-size: 12px;
|
||||
color: #606984;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Slack card */
|
||||
.mokoog-card-sl {
|
||||
border-left: 4px solid #36c5f0;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mokoog-card-sl .mokoog-card-body {
|
||||
border-top: none;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.mokoog-card-sl .mokoog-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1264a3;
|
||||
}
|
||||
|
||||
.mokoog-card-sl .mokoog-card-desc {
|
||||
font-size: 14px;
|
||||
color: #1d1c1d;
|
||||
}
|
||||
|
||||
.mokoog-card-sl .mokoog-card-domain {
|
||||
font-size: 12px;
|
||||
color: #616061;
|
||||
text-transform: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* SEO scoring panel */
|
||||
.mokoog-seo-score { margin: 15px 0; padding: 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #dee2e6; }
|
||||
.mokoog-seo-heading { margin: 0 0 10px; font-size: 14px; color: #666; }
|
||||
.mokoog-seo-list { list-style: none; padding: 0; margin: 0 0 10px; }
|
||||
.mokoog-seo-item { padding: 4px 0; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.mokoog-seo-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.mokoog-seo-pass { background: #2e7d32; }
|
||||
.mokoog-seo-fail { background: #d32f2f; }
|
||||
.mokoog-seo-total { font-size: 14px; font-weight: 600; padding-top: 8px; border-top: 1px solid #dee2e6; }
|
||||
.mokoog-seo-total-good { color: #2e7d32; }
|
||||
.mokoog-seo-total-ok { color: #f57c00; }
|
||||
.mokoog-seo-total-bad { color: #d32f2f; }
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,499 +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();
|
||||
});
|
||||
|
||||
// AI Generate buttons
|
||||
['ogTitle', 'ogDesc'].forEach(function(fieldKey) {
|
||||
var field = fields[fieldKey];
|
||||
if (!field) return;
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-primary mokoog-ai-btn';
|
||||
btn.textContent = 'Generate with AI';
|
||||
btn.dataset.target = fieldKey;
|
||||
field.parentNode.appendChild(btn);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
var articleTitle = fields.articleTitle ? fields.articleTitle.value : '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating...';
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('task', 'mokoog.aiGenerate');
|
||||
formData.append('field', fieldKey === 'ogTitle' ? 'title' : 'description');
|
||||
formData.append('article_title', articleTitle);
|
||||
formData.append(Joomla.getOptions('csrf.token'), 1);
|
||||
|
||||
fetch(window.location.origin + '/administrator/index.php?option=com_ajax&plugin=mokoog&group=system&format=json', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.data && data.data[0]) {
|
||||
field.value = data.data[0];
|
||||
field.dispatchEvent(new Event('input'));
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Generate with AI';
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Generate with AI';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// Discord preview card
|
||||
var dcLabel = document.createElement('small');
|
||||
dcLabel.className = 'mokoog-platform-label';
|
||||
dcLabel.textContent = 'Discord';
|
||||
wrapper.appendChild(dcLabel);
|
||||
|
||||
var dcCard = document.createElement('div');
|
||||
dcCard.className = 'mokoog-card mokoog-card-dc';
|
||||
|
||||
var dcBody = document.createElement('div');
|
||||
dcBody.className = 'mokoog-card-body';
|
||||
|
||||
var dcTitle = document.createElement('div');
|
||||
dcTitle.id = 'mokoog-dc-title';
|
||||
dcTitle.className = 'mokoog-card-title';
|
||||
dcBody.appendChild(dcTitle);
|
||||
|
||||
var dcDesc = document.createElement('div');
|
||||
dcDesc.id = 'mokoog-dc-desc';
|
||||
dcDesc.className = 'mokoog-card-desc';
|
||||
dcBody.appendChild(dcDesc);
|
||||
|
||||
var dcDomain = document.createElement('div');
|
||||
dcDomain.id = 'mokoog-dc-domain';
|
||||
dcDomain.className = 'mokoog-card-domain';
|
||||
dcBody.appendChild(dcDomain);
|
||||
|
||||
dcCard.appendChild(dcBody);
|
||||
|
||||
var dcImg = document.createElement('div');
|
||||
dcImg.id = 'mokoog-dc-img';
|
||||
dcImg.className = 'mokoog-card-img';
|
||||
dcCard.appendChild(dcImg);
|
||||
|
||||
wrapper.appendChild(dcCard);
|
||||
|
||||
// Mastodon preview card
|
||||
var maLabel = document.createElement('small');
|
||||
maLabel.className = 'mokoog-platform-label';
|
||||
maLabel.textContent = 'Mastodon';
|
||||
wrapper.appendChild(maLabel);
|
||||
|
||||
var maCard = document.createElement('div');
|
||||
maCard.className = 'mokoog-card mokoog-card-ma';
|
||||
|
||||
var maImg = document.createElement('div');
|
||||
maImg.id = 'mokoog-ma-img';
|
||||
maImg.className = 'mokoog-card-img';
|
||||
maCard.appendChild(maImg);
|
||||
|
||||
var maBody = document.createElement('div');
|
||||
maBody.className = 'mokoog-card-body';
|
||||
|
||||
var maTitle = document.createElement('div');
|
||||
maTitle.id = 'mokoog-ma-title';
|
||||
maTitle.className = 'mokoog-card-title';
|
||||
maBody.appendChild(maTitle);
|
||||
|
||||
var maDesc = document.createElement('div');
|
||||
maDesc.id = 'mokoog-ma-desc';
|
||||
maDesc.className = 'mokoog-card-desc';
|
||||
maBody.appendChild(maDesc);
|
||||
|
||||
var maDomain = document.createElement('div');
|
||||
maDomain.id = 'mokoog-ma-domain';
|
||||
maDomain.className = 'mokoog-card-domain';
|
||||
maBody.appendChild(maDomain);
|
||||
|
||||
maCard.appendChild(maBody);
|
||||
wrapper.appendChild(maCard);
|
||||
|
||||
// Slack preview card
|
||||
var slLabel = document.createElement('small');
|
||||
slLabel.className = 'mokoog-platform-label';
|
||||
slLabel.textContent = 'Slack';
|
||||
wrapper.appendChild(slLabel);
|
||||
|
||||
var slCard = document.createElement('div');
|
||||
slCard.className = 'mokoog-card mokoog-card-sl';
|
||||
|
||||
var slBody = document.createElement('div');
|
||||
slBody.className = 'mokoog-card-body';
|
||||
|
||||
var slTitle = document.createElement('div');
|
||||
slTitle.id = 'mokoog-sl-title';
|
||||
slTitle.className = 'mokoog-card-title';
|
||||
slBody.appendChild(slTitle);
|
||||
|
||||
var slDesc = document.createElement('div');
|
||||
slDesc.id = 'mokoog-sl-desc';
|
||||
slDesc.className = 'mokoog-card-desc';
|
||||
slBody.appendChild(slDesc);
|
||||
|
||||
var slDomain = document.createElement('div');
|
||||
slDomain.id = 'mokoog-sl-domain';
|
||||
slDomain.className = 'mokoog-card-domain';
|
||||
slBody.appendChild(slDomain);
|
||||
|
||||
slCard.appendChild(slBody);
|
||||
wrapper.appendChild(slCard);
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// Discord (title 256, desc 350)
|
||||
var dcTitle = title.length > 256 ? title.substring(0, 253) + '...' : title;
|
||||
var dcDesc = desc.length > 350 ? desc.substring(0, 347) + '...' : desc;
|
||||
document.getElementById('mokoog-dc-title').textContent = dcTitle;
|
||||
document.getElementById('mokoog-dc-desc').textContent = dcDesc;
|
||||
document.getElementById('mokoog-dc-domain').textContent = domain;
|
||||
var dcImgEl = document.getElementById('mokoog-dc-img');
|
||||
if (img) {
|
||||
dcImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
|
||||
dcImgEl.style.display = '';
|
||||
} else {
|
||||
dcImgEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Mastodon (title 70, desc 200)
|
||||
var maTitle = title.length > 70 ? title.substring(0, 67) + '...' : title;
|
||||
var maDesc = desc.length > 200 ? desc.substring(0, 197) + '...' : desc;
|
||||
document.getElementById('mokoog-ma-title').textContent = maTitle;
|
||||
document.getElementById('mokoog-ma-desc').textContent = maDesc;
|
||||
document.getElementById('mokoog-ma-domain').textContent = domain;
|
||||
var maImgEl = document.getElementById('mokoog-ma-img');
|
||||
if (img) {
|
||||
maImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
|
||||
maImgEl.style.display = '';
|
||||
} else {
|
||||
maImgEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Slack (title 70, desc 150, no image)
|
||||
var slTitle = title.length > 70 ? title.substring(0, 67) + '...' : title;
|
||||
var slDesc = desc.length > 150 ? desc.substring(0, 147) + '...' : desc;
|
||||
document.getElementById('mokoog-sl-title').textContent = slTitle;
|
||||
document.getElementById('mokoog-sl-desc').textContent = slDesc;
|
||||
document.getElementById('mokoog-sl-domain').textContent = domain;
|
||||
}
|
||||
|
||||
// SEO scoring panel
|
||||
var seoChecks = [
|
||||
{ id: 'og-title', label: 'OG Title', check: function() { return fields.ogTitle && fields.ogTitle.value.length > 0; }},
|
||||
{ id: 'og-desc', label: 'OG Description', check: function() { return fields.ogDesc && fields.ogDesc.value.length > 0; }},
|
||||
{ id: 'og-image', label: 'OG Image', check: function() { return fields.ogImage && fields.ogImage.value.length > 0; }},
|
||||
{ id: 'seo-title', label: 'SEO Title', check: function() { return fields.seoTitle && fields.seoTitle.value.length > 0; }},
|
||||
{ id: 'meta-desc', label: 'Meta Description', check: function() { return fields.metaDescription && fields.metaDescription.value.length > 0; }},
|
||||
{ id: 'title-length', label: 'Title Length (\u226460)', check: function() {
|
||||
var t = (fields.ogTitle && fields.ogTitle.value) || (fields.articleTitle && fields.articleTitle.value) || '';
|
||||
return t.length > 0 && t.length <= 60;
|
||||
}},
|
||||
{ id: 'desc-length', label: 'Description Length (\u2264160)', check: function() {
|
||||
var d = (fields.ogDesc && fields.ogDesc.value) || (fields.metaDesc && fields.metaDesc.value) || '';
|
||||
return d.length > 0 && d.length <= 160;
|
||||
}}
|
||||
];
|
||||
|
||||
var seoPanel = document.createElement('div');
|
||||
seoPanel.className = 'mokoog-seo-score';
|
||||
|
||||
var seoHeading = document.createElement('h4');
|
||||
seoHeading.className = 'mokoog-seo-heading';
|
||||
seoHeading.textContent = 'SEO Analysis';
|
||||
seoPanel.appendChild(seoHeading);
|
||||
|
||||
var seoList = document.createElement('ul');
|
||||
seoList.className = 'mokoog-seo-list';
|
||||
|
||||
var seoDots = {};
|
||||
seoChecks.forEach(function (chk) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'mokoog-seo-item';
|
||||
|
||||
var dot = document.createElement('span');
|
||||
dot.className = 'mokoog-seo-dot mokoog-seo-fail';
|
||||
seoDots[chk.id] = dot;
|
||||
li.appendChild(dot);
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.textContent = chk.label;
|
||||
li.appendChild(label);
|
||||
|
||||
seoList.appendChild(li);
|
||||
});
|
||||
|
||||
seoPanel.appendChild(seoList);
|
||||
|
||||
var seoTotal = document.createElement('div');
|
||||
seoTotal.className = 'mokoog-seo-total';
|
||||
seoPanel.appendChild(seoTotal);
|
||||
|
||||
wrapper.parentNode.insertBefore(seoPanel, wrapper.nextSibling);
|
||||
|
||||
function updateSeoScore() {
|
||||
var passed = 0;
|
||||
seoChecks.forEach(function (chk) {
|
||||
var ok = chk.check();
|
||||
if (ok) passed++;
|
||||
seoDots[chk.id].className = 'mokoog-seo-dot ' + (ok ? 'mokoog-seo-pass' : 'mokoog-seo-fail');
|
||||
});
|
||||
|
||||
seoTotal.textContent = passed + '/' + seoChecks.length + ' checks passed';
|
||||
|
||||
if (passed === seoChecks.length) {
|
||||
seoTotal.className = 'mokoog-seo-total mokoog-seo-total-good';
|
||||
} else if (passed >= Math.ceil(seoChecks.length / 2)) {
|
||||
seoTotal.className = 'mokoog-seo-total mokoog-seo-total-ok';
|
||||
} else {
|
||||
seoTotal.className = 'mokoog-seo-total mokoog-seo-total-bad';
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(fields).forEach(function (el) {
|
||||
if (el) {
|
||||
el.addEventListener('input', function () { updatePreview(); updateSeoScore(); });
|
||||
el.addEventListener('change', function () { updatePreview(); updateSeoScore(); });
|
||||
}
|
||||
});
|
||||
|
||||
if (fields.ogImage) {
|
||||
var observer = new MutationObserver(function () { updatePreview(); updateSeoScore(); });
|
||||
observer.observe(fields.ogImage, { attributes: true, attributeFilter: ['value'] });
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
updateSeoScore();
|
||||
});
|
||||
@@ -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,95 +0,0 @@
|
||||
; MokoJoomOpenGraph - System Plugin Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_BASIC="Basic Settings"
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED="Advanced Settings"
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME="Site Name"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC="The og:site_name value. Leave blank to use the Joomla site name."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE="Default OG Title"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE_DESC="Site-wide fallback title for social sharing. Used when a page has no custom OG title. Leave blank to use the page title."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION="Default OG Description"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION_DESC="Site-wide fallback description for social sharing. Used when a page has no custom OG description and no meta description. Leave blank to auto-generate from page content."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE="Default Image"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE_DESC="Fallback image used when no article or page image is found."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD="Twitter Card Type"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD_DESC="The type of Twitter Card to generate."
|
||||
PLG_SYSTEM_MOKOOG_CARD_SUMMARY="Summary"
|
||||
PLG_SYSTEM_MOKOOG_CARD_SUMMARY_LARGE="Summary with Large Image"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE="Twitter @username"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE_DESC="Your site's Twitter handle (e.g. @mokoconsulting)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR="Fediverse Creator"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR_DESC="Your Fediverse/Mastodon handle (e.g. @user@mastodon.social). Outputs a fediverse:creator meta tag for author attribution on Mastodon and other Fediverse platforms."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML_DESC="Remove HTML tags from the auto-generated description."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH="Description Length"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH_DESC="Maximum character length for the auto-generated og:description."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL="Telegram Channel"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL_DESC="Your Telegram channel handle (e.g. @mokoconsulting). Outputs a telegram:channel meta tag for Telegram link previews."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ="JSON-LD FAQ Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC="Auto-detect FAQ sections from article headings (h3/h4 + paragraphs) and output FAQPage structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO="JSON-LD HowTo Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC="Auto-detect step-by-step instructions from ordered lists (ol/li) and output HowTo structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_LOCALBUSINESS="Local Business"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED="Enable LocalBusiness Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED_DESC="Output LocalBusiness JSON-LD structured data on all pages."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME="Business Name"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME_DESC="Your business name for structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE="Business Type"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE_DESC="Schema.org business type."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET="Street Address"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET_DESC="Street address of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY="City"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY_DESC="City of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION="State/Region"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION_DESC="State or region of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL="Postal Code"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL_DESC="Postal/ZIP code of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY="Country"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY_DESC="Country code (e.g. US, GB, DE)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE="Phone"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE_DESC="Business phone number."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL="Email"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL_DESC="Business email address."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_URL="Website URL"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_URL_DESC="Business website URL."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS="Opening Hours"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS_DESC="Opening hours in schema.org format (e.g. Mo-Fr 09:00-17:00)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE="Latitude"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE_DESC="Geographic latitude of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE="Longitude"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC="Geographic longitude of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE="Price Range"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC="Price range indicator (e.g. $, $$, $$$)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE="Per-platform Image Sizes"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC="Generate platform-specific image sizes (Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400) in addition to the default Facebook 1200x630."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP="XML Sitemap"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED="Enable Sitemap"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC="Auto-generate sitemap.xml when articles are saved. Respects noindex robots directives."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ="Change Frequency"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC="Default change frequency for sitemap entries."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_AI="AI Meta Generation"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED="Enable AI Generation"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC="Show Generate with AI buttons next to OG title and description fields."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER="AI Provider"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC="Select the AI API provider."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY="API Key"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC="Your AI provider API key."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL="Model"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC="AI model to use for generation."
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,95 +0,0 @@
|
||||
; MokoJoomOpenGraph - System Plugin Language File
|
||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; License: GPL-3.0-or-later
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_BASIC="Basic Settings"
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED="Advanced Settings"
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME="Site Name"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC="The og:site_name value. Leave blank to use the Joomla site name."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE="Default OG Title"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE_DESC="Site-wide fallback title for social sharing. Used when a page has no custom OG title. Leave blank to use the page title."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION="Default OG Description"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION_DESC="Site-wide fallback description for social sharing. Used when a page has no custom OG description and no meta description. Leave blank to auto-generate from page content."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE="Default Image"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE_DESC="Fallback image used when no article or page image is found."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD="Twitter Card Type"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD_DESC="The type of Twitter Card to generate."
|
||||
PLG_SYSTEM_MOKOOG_CARD_SUMMARY="Summary"
|
||||
PLG_SYSTEM_MOKOOG_CARD_SUMMARY_LARGE="Summary with Large Image"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE="Twitter @username"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE_DESC="Your site's Twitter handle (e.g. @mokoconsulting)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR="Fediverse Creator"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR_DESC="Your Fediverse/Mastodon handle (e.g. @user@mastodon.social). Outputs a fediverse:creator meta tag for author attribution on Mastodon and other Fediverse platforms."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML_DESC="Remove HTML tags from the auto-generated description."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH="Description Length"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH_DESC="Maximum character length for the auto-generated og:description."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL="Telegram Channel"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL_DESC="Your Telegram channel handle (e.g. @mokoconsulting). Outputs a telegram:channel meta tag for Telegram link previews."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ="JSON-LD FAQ Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC="Auto-detect FAQ sections from article headings (h3/h4 + paragraphs) and output FAQPage structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO="JSON-LD HowTo Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC="Auto-detect step-by-step instructions from ordered lists (ol/li) and output HowTo structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_LOCALBUSINESS="Local Business"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED="Enable LocalBusiness Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED_DESC="Output LocalBusiness JSON-LD structured data on all pages."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME="Business Name"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME_DESC="Your business name for structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE="Business Type"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE_DESC="Schema.org business type."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET="Street Address"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET_DESC="Street address of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY="City"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY_DESC="City of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION="State/Region"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION_DESC="State or region of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL="Postal Code"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL_DESC="Postal/ZIP code of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY="Country"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY_DESC="Country code (e.g. US, GB, DE)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE="Phone"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE_DESC="Business phone number."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL="Email"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL_DESC="Business email address."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_URL="Website URL"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_URL_DESC="Business website URL."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS="Opening Hours"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS_DESC="Opening hours in schema.org format (e.g. Mo-Fr 09:00-17:00)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE="Latitude"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE_DESC="Geographic latitude of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE="Longitude"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC="Geographic longitude of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE="Price Range"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC="Price range indicator (e.g. $, $$, $$$)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE="Per-platform Image Sizes"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC="Generate platform-specific image sizes (Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400) in addition to the default Facebook 1200x630."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP="XML Sitemap"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED="Enable Sitemap"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC="Auto-generate sitemap.xml when articles are saved. Respects noindex robots directives."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ="Change Frequency"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC="Default change frequency for sitemap entries."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_AI="AI Meta Generation"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED="Enable AI Generation"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC="Show Generate with AI buttons next to OG title and description fields."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER="AI Provider"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC="Select the AI API provider."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY="API Key"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC="Your AI provider API key."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL="Model"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC="AI model to use for generation."
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,371 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @package MokoJoomOpenGraph
|
||||
* @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
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoJoomOpenGraph</name>
|
||||
<version>01.04.08</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>PLG_SYSTEM_MOKOOG_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Plugin\System\MokoOG</namespace>
|
||||
|
||||
<files>
|
||||
<filename plugin="mokoog">mokoog.php</filename>
|
||||
<folder>src</folder>
|
||||
<folder>services</folder>
|
||||
<folder>language</folder>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_system_mokoog.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_system_mokoog.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic" label="PLG_SYSTEM_MOKOOG_FIELDSET_BASIC">
|
||||
<field
|
||||
name="og_site_name"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_SITE_NAME_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="default_og_title"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_TITLE_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="default_og_description"
|
||||
type="textarea"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_OG_DESCRIPTION_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
rows="3"
|
||||
/>
|
||||
<field
|
||||
name="default_image"
|
||||
type="media"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_DEFAULT_IMAGE_DESC"
|
||||
directory="mokoog"
|
||||
/>
|
||||
<field
|
||||
name="twitter_card_type"
|
||||
type="list"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_TWITTER_CARD_DESC"
|
||||
default="summary_large_image"
|
||||
>
|
||||
<option value="summary">PLG_SYSTEM_MOKOOG_CARD_SUMMARY</option>
|
||||
<option value="summary_large_image">PLG_SYSTEM_MOKOOG_CARD_SUMMARY_LARGE</option>
|
||||
</field>
|
||||
<field
|
||||
name="twitter_site"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_TWITTER_SITE_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="fb_app_id"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="telegram_channel"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_TELEGRAM_CHANNEL_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="discord_color"
|
||||
type="color"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="fediverse_creator"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="advanced" label="PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED">
|
||||
<field
|
||||
name="auto_generate"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="strip_html"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="desc_length"
|
||||
type="number"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_DESC_LENGTH_DESC"
|
||||
default="160"
|
||||
min="50"
|
||||
max="300"
|
||||
/>
|
||||
<field
|
||||
name="auto_resize"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="platform_resize"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_enabled"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_faq"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_howto"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_breadcrumbs"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="localbusiness" label="PLG_SYSTEM_MOKOOG_FIELDSET_LOCALBUSINESS">
|
||||
<field
|
||||
name="lb_enabled"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="lb_name"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_NAME"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_NAME_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_type"
|
||||
type="list"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE_DESC"
|
||||
default="LocalBusiness"
|
||||
>
|
||||
<option value="LocalBusiness">LocalBusiness</option>
|
||||
<option value="Restaurant">Restaurant</option>
|
||||
<option value="Store">Store</option>
|
||||
<option value="MedicalBusiness">MedicalBusiness</option>
|
||||
<option value="LegalService">LegalService</option>
|
||||
<option value="FinancialService">FinancialService</option>
|
||||
<option value="EducationalOrganization">EducationalOrganization</option>
|
||||
</field>
|
||||
<field
|
||||
name="lb_street"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_STREET"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_STREET_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_city"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_CITY"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_CITY_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_region"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_REGION"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_REGION_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_postal"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_country"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY_DESC"
|
||||
default="US"
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_phone"
|
||||
type="tel"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="lb_email"
|
||||
type="email"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="lb_url"
|
||||
type="url"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_URL"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_URL_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="lb_opening_hours"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_latitude"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_longitude"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_price_range"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="sitemap" label="PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP">
|
||||
<field name="sitemap_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC" default="0" class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="sitemap_changefreq" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC" default="weekly">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="ai" label="PLG_SYSTEM_MOKOOG_FIELDSET_AI">
|
||||
<field name="ai_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC" default="0" class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="ai_provider" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER" description="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC" default="claude">
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
</field>
|
||||
<field name="ai_api_key" type="password" label="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY" description="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC" filter="string" />
|
||||
<field name="ai_model" type="text" label="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL" description="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC" default="claude-haiku-4-5-20251001" filter="string" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
<html><body bgcolor="#FFFFFF"></body></html>
|
||||
@@ -1,674 +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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build VideoObject schema for pages with a video URL.
|
||||
*
|
||||
* @param string $videoUrl Video URL (e.g. YouTube, Vimeo, or direct)
|
||||
* @param string $title Video title
|
||||
* @param string $description Video description
|
||||
* @param string $imageUrl Thumbnail image URL (absolute)
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildVideo(string $videoUrl, string $title, string $description, string $imageUrl): ?array
|
||||
{
|
||||
if (empty($videoUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'VideoObject',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'thumbnailUrl' => $imageUrl,
|
||||
'contentUrl' => $videoUrl,
|
||||
'uploadDate' => Factory::getDate()->toISO8601(),
|
||||
];
|
||||
|
||||
// Add embedUrl for YouTube and Vimeo
|
||||
if (preg_match('/youtube\.com|youtu\.be|vimeo\.com/i', $videoUrl)) {
|
||||
$schema['embedUrl'] = $videoUrl;
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LocalBusiness schema from plugin parameters.
|
||||
*
|
||||
* @param object $params Plugin parameters object
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildLocalBusiness(object $params): ?array
|
||||
{
|
||||
$name = trim((string) $params->get('lb_name', ''));
|
||||
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => $params->get('lb_type', 'LocalBusiness'),
|
||||
'name' => $name,
|
||||
];
|
||||
|
||||
// Build PostalAddress
|
||||
$address = [];
|
||||
$street = trim((string) $params->get('lb_street', ''));
|
||||
$city = trim((string) $params->get('lb_city', ''));
|
||||
$region = trim((string) $params->get('lb_region', ''));
|
||||
$postal = trim((string) $params->get('lb_postal', ''));
|
||||
$country = trim((string) $params->get('lb_country', ''));
|
||||
|
||||
if ($street !== '') {
|
||||
$address['streetAddress'] = $street;
|
||||
}
|
||||
|
||||
if ($city !== '') {
|
||||
$address['addressLocality'] = $city;
|
||||
}
|
||||
|
||||
if ($region !== '') {
|
||||
$address['addressRegion'] = $region;
|
||||
}
|
||||
|
||||
if ($postal !== '') {
|
||||
$address['postalCode'] = $postal;
|
||||
}
|
||||
|
||||
if ($country !== '') {
|
||||
$address['addressCountry'] = $country;
|
||||
}
|
||||
|
||||
if (!empty($address)) {
|
||||
$address['@type'] = 'PostalAddress';
|
||||
$schema['address'] = $address;
|
||||
}
|
||||
|
||||
// Contact properties
|
||||
$phone = trim((string) $params->get('lb_phone', ''));
|
||||
$email = trim((string) $params->get('lb_email', ''));
|
||||
$url = trim((string) $params->get('lb_url', ''));
|
||||
|
||||
if ($phone !== '') {
|
||||
$schema['telephone'] = $phone;
|
||||
}
|
||||
|
||||
if ($email !== '') {
|
||||
$schema['email'] = $email;
|
||||
}
|
||||
|
||||
if ($url !== '') {
|
||||
$schema['url'] = $url;
|
||||
}
|
||||
|
||||
// Opening hours
|
||||
$openingHours = trim((string) $params->get('lb_opening_hours', ''));
|
||||
|
||||
if ($openingHours !== '') {
|
||||
$schema['openingHours'] = $openingHours;
|
||||
}
|
||||
|
||||
// GeoCoordinates
|
||||
$latitude = trim((string) $params->get('lb_latitude', ''));
|
||||
$longitude = trim((string) $params->get('lb_longitude', ''));
|
||||
|
||||
if ($latitude !== '' && $longitude !== '') {
|
||||
$schema['geo'] = [
|
||||
'@type' => 'GeoCoordinates',
|
||||
'latitude' => $latitude,
|
||||
'longitude' => $longitude,
|
||||
];
|
||||
}
|
||||
|
||||
// Price range
|
||||
$priceRange = trim((string) $params->get('lb_price_range', ''));
|
||||
|
||||
if ($priceRange !== '') {
|
||||
$schema['priceRange'] = $priceRange;
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FAQPage schema from question/answer pairs.
|
||||
*
|
||||
* @param array $questions Array of ['question' => '...', 'answer' => '...'] pairs
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildFaq(array $questions): ?array
|
||||
{
|
||||
if (empty($questions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mainEntity = [];
|
||||
|
||||
foreach ($questions as $item) {
|
||||
$question = trim($item['question'] ?? '');
|
||||
$answer = trim($item['answer'] ?? '');
|
||||
|
||||
if ($question === '' || $answer === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mainEntity[] = [
|
||||
'@type' => 'Question',
|
||||
'name' => $question,
|
||||
'acceptedAnswer' => [
|
||||
'@type' => 'Answer',
|
||||
'text' => $answer,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($mainEntity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'FAQPage',
|
||||
'mainEntity' => $mainEntity,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HowTo schema from step-by-step instructions.
|
||||
*
|
||||
* @param string $title HowTo title
|
||||
* @param array $steps Array of ['name' => 'Step title', 'text' => 'Step instructions']
|
||||
* @param string $imageUrl Optional image URL (absolute)
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildHowTo(string $title, array $steps, string $imageUrl = ''): ?array
|
||||
{
|
||||
if (empty($steps)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'HowTo',
|
||||
'name' => $title,
|
||||
];
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
$schema['image'] = $imageUrl;
|
||||
}
|
||||
|
||||
$schema['step'] = [];
|
||||
|
||||
foreach ($steps as $step) {
|
||||
$schema['step'][] = [
|
||||
'@type' => 'HowToStep',
|
||||
'name' => $step['name'],
|
||||
'text' => $step['text'],
|
||||
];
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Event schema from per-article event data.
|
||||
*
|
||||
* @param string $title Event/article title
|
||||
* @param string $description Event description
|
||||
* @param string $imageUrl Image URL (absolute)
|
||||
* @param object $eventData Decoded event_data with event_start, event_end, etc.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildEvent(string $title, string $description, string $imageUrl, object $eventData): ?array
|
||||
{
|
||||
$startDate = $eventData->event_start ?? '';
|
||||
|
||||
if (empty($startDate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Event',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'startDate' => $startDate,
|
||||
'url' => Uri::getInstance()->toString(),
|
||||
];
|
||||
|
||||
$endDate = $eventData->event_end ?? '';
|
||||
|
||||
if (!empty($endDate)) {
|
||||
$schema['endDate'] = $endDate;
|
||||
}
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
$schema['image'] = $imageUrl;
|
||||
}
|
||||
|
||||
$locationName = $eventData->event_location ?? '';
|
||||
$address = $eventData->event_address ?? '';
|
||||
|
||||
if (!empty($locationName) || !empty($address)) {
|
||||
$location = ['@type' => 'Place'];
|
||||
|
||||
if (!empty($locationName)) {
|
||||
$location['name'] = $locationName;
|
||||
}
|
||||
|
||||
if (!empty($address)) {
|
||||
$location['address'] = [
|
||||
'@type' => 'PostalAddress',
|
||||
'streetAddress' => $address,
|
||||
];
|
||||
}
|
||||
|
||||
$schema['location'] = $location;
|
||||
}
|
||||
|
||||
$price = $eventData->event_price ?? '';
|
||||
$currency = $eventData->event_currency ?? 'USD';
|
||||
$ticketUrl = $eventData->event_url ?? '';
|
||||
|
||||
if ($price !== '') {
|
||||
$offer = [
|
||||
'@type' => 'Offer',
|
||||
'price' => number_format((float) $price, 2, '.', ''),
|
||||
'priceCurrency' => $currency ?: 'USD',
|
||||
'availability' => 'https://schema.org/InStock',
|
||||
];
|
||||
|
||||
if (!empty($ticketUrl)) {
|
||||
$offer['url'] = $ticketUrl;
|
||||
}
|
||||
|
||||
$schema['offers'] = $offer;
|
||||
} elseif (!empty($ticketUrl)) {
|
||||
$schema['offers'] = [
|
||||
'@type' => 'Offer',
|
||||
'price' => '0.00',
|
||||
'priceCurrency' => $currency ?: 'USD',
|
||||
'availability' => 'https://schema.org/InStock',
|
||||
'url' => $ticketUrl,
|
||||
];
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Recipe schema from per-article recipe data.
|
||||
*
|
||||
* @param string $title Recipe/article title
|
||||
* @param string $description Recipe/article description
|
||||
* @param string $imageUrl Image URL (absolute)
|
||||
* @param object $recipeData Decoded recipe_data object
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildRecipe(string $title, string $description, string $imageUrl, object $recipeData): ?array
|
||||
{
|
||||
$fields = ['recipe_prep_time', 'recipe_cook_time', 'recipe_yield', 'recipe_calories', 'recipe_ingredients', 'recipe_category', 'recipe_cuisine'];
|
||||
$hasData = false;
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (!empty($recipeData->$field)) {
|
||||
$hasData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Recipe',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'url' => Uri::getInstance()->toString(),
|
||||
];
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
$schema['image'] = $imageUrl;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_prep_time)) {
|
||||
$schema['prepTime'] = $recipeData->recipe_prep_time;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_cook_time)) {
|
||||
$schema['cookTime'] = $recipeData->recipe_cook_time;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_prep_time) && !empty($recipeData->recipe_cook_time)) {
|
||||
try {
|
||||
$prep = new \DateInterval($recipeData->recipe_prep_time);
|
||||
$cook = new \DateInterval($recipeData->recipe_cook_time);
|
||||
$totalMinutes = ($prep->h * 60 + $prep->i) + ($cook->h * 60 + $cook->i);
|
||||
$hours = intdiv($totalMinutes, 60);
|
||||
$minutes = $totalMinutes % 60;
|
||||
$totalTime = 'PT';
|
||||
|
||||
if ($hours > 0) {
|
||||
$totalTime .= $hours . 'H';
|
||||
}
|
||||
|
||||
if ($minutes > 0) {
|
||||
$totalTime .= $minutes . 'M';
|
||||
}
|
||||
|
||||
if ($totalTime !== 'PT') {
|
||||
$schema['totalTime'] = $totalTime;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Invalid duration format
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_yield)) {
|
||||
$schema['recipeYield'] = $recipeData->recipe_yield;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_calories)) {
|
||||
$schema['nutrition'] = [
|
||||
'@type' => 'NutritionInformation',
|
||||
'calories' => $recipeData->recipe_calories . ' calories',
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_ingredients)) {
|
||||
$ingredients = array_filter(
|
||||
array_map('trim', preg_split('/\r\n|\r|\n/', $recipeData->recipe_ingredients)),
|
||||
fn($line) => $line !== ''
|
||||
);
|
||||
|
||||
if (!empty($ingredients)) {
|
||||
$schema['recipeIngredient'] = array_values($ingredients);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_category)) {
|
||||
$schema['recipeCategory'] = $recipeData->recipe_category;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_cuisine)) {
|
||||
$schema['recipeCuisine'] = $recipeData->recipe_cuisine;
|
||||
}
|
||||
|
||||
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,107 +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;
|
||||
|
||||
/**
|
||||
* XML Sitemap builder.
|
||||
*
|
||||
* Generates a sitemap.xml containing all published articles, excluding
|
||||
* those marked with noindex robots directives in the mokoog_tags table.
|
||||
*/
|
||||
class SitemapBuilder
|
||||
{
|
||||
/**
|
||||
* Generate sitemap XML content.
|
||||
*
|
||||
* @param string $changefreq Default change frequency for entries
|
||||
*
|
||||
* @return string Complete sitemap XML
|
||||
*/
|
||||
public static function generate(string $changefreq = 'weekly'): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Get all published articles
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language']))
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->where($db->quoteName('a.state') . ' = 1');
|
||||
|
||||
$db->setQuery($query);
|
||||
$articles = $db->loadObjectList();
|
||||
|
||||
// Get noindex articles from mokoog_tags
|
||||
$noindexQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('content_id'))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
|
||||
->where($db->quoteName('robots') . ' LIKE ' . $db->quote('%noindex%'));
|
||||
|
||||
$db->setQuery($noindexQuery);
|
||||
$noindexIds = $db->loadColumn();
|
||||
|
||||
$root = rtrim(Uri::root(), '/');
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
|
||||
// Homepage
|
||||
$xml .= ' <url>' . "\n";
|
||||
$xml .= ' <loc>' . $root . '/</loc>' . "\n";
|
||||
$xml .= ' <changefreq>daily</changefreq>' . "\n";
|
||||
$xml .= ' <priority>1.0</priority>' . "\n";
|
||||
$xml .= ' </url>' . "\n";
|
||||
|
||||
foreach ($articles as $article) {
|
||||
// Skip noindexed
|
||||
if (in_array((int) $article->id, $noindexIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $root . '/index.php?option=com_content&view=article&id=' . $article->id;
|
||||
$lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00'
|
||||
? date('Y-m-d', strtotime($article->modified)) : '';
|
||||
|
||||
$xml .= ' <url>' . "\n";
|
||||
$xml .= ' <loc>' . htmlspecialchars($url, ENT_XML1) . '</loc>' . "\n";
|
||||
|
||||
if ($lastmod) {
|
||||
$xml .= ' <lastmod>' . $lastmod . '</lastmod>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= ' <changefreq>' . $changefreq . '</changefreq>' . "\n";
|
||||
$xml .= ' <priority>0.8</priority>' . "\n";
|
||||
$xml .= ' </url>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= '</urlset>';
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write sitemap XML to the site root.
|
||||
*
|
||||
* @param string $xml The sitemap XML content
|
||||
*
|
||||
* @return bool True on success
|
||||
*/
|
||||
public static function writeToFile(string $xml): bool
|
||||
{
|
||||
$path = JPATH_ROOT . '/sitemap.xml';
|
||||
|
||||
return (bool) file_put_contents($path, $xml);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user