diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..998448a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,62 @@ +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8affbb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,204 @@ +# ============================================================ +# Local task tracking (not version controlled) +# ============================================================ +TODO.md + +# ============================================================ +# Environment and secrets +# ============================================================ +.env +.env.local +.env.*.local +*.local.php +*.secret.php +configuration.php +configuration.*.php +configuration.local.php +conf/conf.php +conf/conf*.php +secrets/ +*.secrets.* + +# ============================================================ +# Logs, dumps and databases +# ============================================================ +*.db +*.db-journal +*.dump +*.log +*.pid +*.seed + + +# ============================================================ +# OS / Editor / IDE cruft +# ============================================================ +.DS_Store +Thumbs.db +desktop.ini +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +$RECYCLE.BIN/ +System Volume Information/ +*.lnk +Icon? +.idea/ +.settings/ +.claude/ +.vscode/* +!.vscode/tasks.json +!.vscode/settings.json.example +!.vscode/extensions.json +*.code-workspace +*.sublime* +.project +.buildpath +.classpath +*.bak +*.swp +*.swo +*.tmp +*.old +*.orig + +# ============================================================ +# Dev scripts and scratch +# ============================================================ +TODO.md +todo* +*ffs* + +# ============================================================ +# SFTP / sync tools +# ============================================================ +sftp-config*.json +sftp-config.json.template +sftp-settings.json + +# ============================================================ +# Sublime SFTP / FTP sync +# ============================================================ +*.sublime-project +*.sublime-workspace +*.sublime-settings +.libsass.json +*.ffs* + +# ============================================================ +# Replit / cloud IDE +# ============================================================ +.replit +replit.md + +# ============================================================ +# Archives / release artifacts +# ============================================================ +*.7z +*.rar +*.tar +*.tar.gz +*.tgz +*.zip +artifacts/ +release/ +releases/ + +# ============================================================ +# Build outputs and site generators +# ============================================================ +.mkdocs-build/ +.cache/ +.parcel-cache/ +build/ +dist/ +out/ +/site/ +*.map +*.css.map +*.js.map +*.tsbuildinfo + +# ============================================================ +# CI / test artifacts +# ============================================================ +.coverage +.coverage.* +coverage/ +coverage.xml +htmlcov/ +junit.xml +reports/ +test-results/ +tests/_output/ +.github/local/ +.github/workflows/*.log + +# ============================================================ +# Node / JavaScript +# ============================================================ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-store/ +.yarn/ +.npmrc +.eslintcache +package-lock.json + +# ============================================================ +# PHP / Composer tooling +# ============================================================ +vendor/ +!source/media/vendor/ +composer.lock +*.phar +codeception.phar +.phpunit.result.cache +.php_cs.cache +.php-cs-fixer.cache +.phpstan.cache +.phplint-cache +phpmd-cache/ +.psalm/ +.rector/ + +# ============================================================ +# Python +# ============================================================ +__pycache__/ +*.py[cod] +*.pyc +*$py.class +*.so +.Python +.eggs/ +*.egg +*.egg-info/ +.installed.cfg +MANIFEST +develop-eggs/ +downloads/ +eggs/ +parts/ +sdist/ +var/ +wheels/ +ENV/ +env/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pyright/ +.tox/ +.nox/ +*.cover +*.coverage +hypothesis/ + +profile.ps1 +.mcp.json diff --git a/.mokogitea/CLAUDE.md b/.mokogitea/CLAUDE.md new file mode 100644 index 0000000..8aefbbf --- /dev/null +++ b/.mokogitea/CLAUDE.md @@ -0,0 +1,67 @@ +# 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 `` and `` +- 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 diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml index cb078c6..6c13103 100644 --- a/.mokogitea/workflows/auto-bump.yml +++ b/.mokogitea/workflows/auto-bump.yml @@ -1,66 +1,66 @@ -# Copyright (C) 2026 Moko Consulting -# -# 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" +# Copyright (C) 2026 Moko Consulting +# +# 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" diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml index 3a81856..29ca4d4 100644 --- a/.mokogitea/workflows/cleanup.yml +++ b/.mokogitea/workflows/cleanup.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Maintenance -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# INGROUP: moko-platform.Maintenance +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/cleanup.yml # VERSION: 01.00.00 # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs @@ -33,17 +33,17 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GA_TOKEN }} + token: ${{ secrets.MOKOGITEA_TOKEN }} - name: Delete merged branches env: - GA_TOKEN: ${{ secrets.GA_TOKEN }} + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} run: | echo "=== Merged Branch Cleanup ===" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" # List branches via API - BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \ + BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \ "${API}/branches?limit=50" | jq -r '.[].name') DELETED=0 @@ -56,7 +56,7 @@ jobs: # Check if branch is merged into main if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then echo " Deleting merged branch: ${BRANCH}" - curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \ + curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ "${API}/branches/${BRANCH}" 2>/dev/null || true DELETED=$((DELETED + 1)) fi @@ -66,20 +66,20 @@ jobs: - name: Clean old workflow runs env: - GA_TOKEN: ${{ secrets.GA_TOKEN }} + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} run: | echo "=== Workflow Run Cleanup ===" API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ) # Get old completed runs - RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \ + RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \ "${API}/actions/runs?status=completed&limit=50" | \ jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null) DELETED=0 for RUN_ID in $RUNS; do - curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \ + curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true DELETED=$((DELETED + 1)) done diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml index 196cf0c..24e1546 100644 --- a/.mokogitea/workflows/gitleaks.yml +++ b/.mokogitea/workflows/gitleaks.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Security -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# INGROUP: moko-platform.Security +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # PATH: /templates/workflows/gitleaks.yml.template # VERSION: 01.00.00 # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 75a6963..3bb20cf 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# VERSION: 01.01.01 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml index 51dfcb5..cde4541 100644 --- a/.mokogitea/workflows/notify.yml +++ b/.mokogitea/workflows/notify.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Notifications -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# INGROUP: moko-platform.Notifications +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/notify.yml # VERSION: 01.00.00 # BRIEF: Push notifications via ntfy on release success or workflow failure diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index d34108c..b1037e7 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -1,534 +1,534 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.CI -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform -# PATH: /templates/workflows/universal/pr-check.yml.template -# VERSION: 09.23.00 -# BRIEF: PR gate — branch policy + code validation before merge - -name: "Universal: PR Check" - -on: - pull_request: - types: [opened, synchronize, reopened, edited] - -permissions: - contents: read - pull-requests: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - # ── Branch Policy ────────────────────────────────────────────────────── - branch-policy: - name: Branch Policy - runs-on: ubuntu-latest - steps: - - name: Check branch merge target - run: | - HEAD="${{ github.head_ref }}" - BASE="${{ github.base_ref }}" - - echo "PR: ${HEAD} → ${BASE}" - - ALLOWED=true - REASON="" - - case "$HEAD" in - feature/*|feat/*) - if [ "$BASE" != "dev" ]; then - ALLOWED=false - REASON="Feature branches must target 'dev', not '${BASE}'" - fi - ;; - fix/*|bugfix/*) - if [ "$BASE" != "dev" ]; then - ALLOWED=false - REASON="Fix branches must target 'dev', not '${BASE}'" - fi - ;; - patch/*) - if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then - ALLOWED=false - REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'" - fi - ;; - hotfix/*) - if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then - ALLOWED=false - REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" - fi - ;; - rc) - if [ "$BASE" != "main" ]; then - ALLOWED=false - REASON="RC branch can only merge into 'main', not '${BASE}'" - fi - ;; - dev) - if [ "$BASE" != "main" ]; then - ALLOWED=false - REASON="Dev branch can only merge into 'main', not '${BASE}'" - fi - ;; - esac - - if [ "$ALLOWED" = false ]; then - echo "::error::${REASON}" - echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "${REASON}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY - echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY - echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY - echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY - exit 1 - fi - - echo "Branch policy: OK (${HEAD} → ${BASE})" - echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY - - # ── Secret Scanning ────────────────────────────────────────────────── - gitleaks: - name: Secret Scan - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install Gitleaks - run: | - GITLEAKS_VERSION="8.21.2" - curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ - | tar -xz -C /usr/local/bin gitleaks - - - name: Scan PR commits for secrets - run: | - if gitleaks detect --source . --verbose \ - --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then - echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY - else - echo "::error::Potential secrets detected in PR commits" - exit 1 - fi - - # ── Code Validation ──────────────────────────────────────────────────── - validate: - name: Validate PR - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Check for merge conflict markers - run: | - CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) - if [ -n "$CONFLICTS" ]; then - echo "::error::Merge conflict markers found in source files" - echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - exit 1 - fi - echo "No conflict markers found" - - - name: Detect platform - id: platform - run: | - # Read platform from XML manifest ( tag) or plain text fallback - PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) - [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') - [ -z "$PLATFORM" ] && PLATFORM="generic" - echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" - - - name: Setup PHP - if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' - run: | - if ! command -v php &> /dev/null; then - sudo apt-get update -qq - sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1 - fi - - - name: PHP syntax check - if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' - run: | - ERRORS=0 - while IFS= read -r -d '' file; do - if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then - ERRORS=$((ERRORS + 1)) - fi - done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0) - echo "PHP lint: ${ERRORS} error(s)" - [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; } - - - name: Joomla JEXEC guard check - if: steps.platform.outputs.platform == 'joomla' - run: | - ERRORS=0 - while IFS= read -r -d '' file; do - # Skip vendor, node_modules, and index.html stub files - case "$file" in ./vendor/*|./node_modules/*) continue ;; esac - # Check first 10 lines for JEXEC or JPATH guard - if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then - echo "::error file=${file}::Missing JEXEC guard: ${file}" - ERRORS=$((ERRORS + 1)) - fi - done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0) - if [ "$ERRORS" -gt 0 ]; then - echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard" - echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY - echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY - exit 1 - fi - echo "JEXEC guard: OK" - - - name: Joomla directory listing protection - if: steps.platform.outputs.platform == 'joomla' - run: | - MISSING=0 - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && exit 0 - while IFS= read -r dir; do - if [ ! -f "${dir}/index.html" ]; then - echo "::warning::Missing index.html in ${dir} (directory listing protection)" - MISSING=$((MISSING + 1)) - fi - done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*") - if [ "$MISSING" -gt 0 ]; then - echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY - echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY - fi - echo "Directory protection: ${MISSING} missing (advisory)" - - - name: Joomla script file and asset checks - if: steps.platform.outputs.platform == 'joomla' - run: | - ERRORS=0 - MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) - [ -z "$MANIFEST" ] && exit 0 - MANIFEST_DIR=$(dirname "$MANIFEST") - - # Check scriptfile exists if declared - SCRIPTFILE=$(sed -n 's/.*\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null) - if [ -n "$SCRIPTFILE" ]; then - if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then - echo "::error::Manifest declares ${SCRIPTFILE} but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}" - ERRORS=$((ERRORS + 1)) - else - echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)" - fi - fi - - # Require joomla.asset.json and validate it - ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1) - if [ -z "$ASSET_JSON" ]; then - echo "::error::joomla.asset.json not found — Joomla asset system is required" - ERRORS=$((ERRORS + 1)) - else - if command -v php &> /dev/null; then - php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || { - echo "::error::joomla.asset.json is not valid JSON" - ERRORS=$((ERRORS + 1)) - } - fi - echo "joomla.asset.json: valid" - fi - - # Validate all XML files in src/ are well-formed - XML_ERRORS=0 - if command -v php &> /dev/null; then - while IFS= read -r -d '' xmlfile; do - if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then - XML_ERRORS=$((XML_ERRORS + 1)) - fi - done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0) - fi - if [ "$XML_ERRORS" -gt 0 ]; then - echo "::error::${XML_ERRORS} XML file(s) are malformed" - ERRORS=$((ERRORS + 1)) - else - echo "XML well-formedness: OK" - fi - - [ "$ERRORS" -gt 0 ] && exit 1 - echo "Joomla asset checks: OK" - - - name: Validate platform manifest - run: | - PLATFORM="${{ steps.platform.outputs.platform }}" - case "$PLATFORM" in - joomla) - MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) - if [ -z "$MANIFEST" ]; then - echo "::warning::No Joomla manifest found (WaaS site)" - exit 0 - fi - echo "Manifest: ${MANIFEST}" - if command -v php &> /dev/null; then - php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; } - fi - for ELEMENT in name version description; do - grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; } - done - # Block legacy raw/branch update server URLs on MokoGitea - RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true) - if [ -n "$RAW_URLS" ]; then - echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" - echo "$RAW_URLS" - exit 1 - fi - echo "Joomla manifest valid" - ;; - dolibarr) - MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) - if [ -z "$MOD_FILE" ]; then - echo "::error::No mod*.class.php found" - exit 1 - fi - echo "Dolibarr module: ${MOD_FILE}" - ;; - *) - echo "Generic platform — no manifest validation" - ;; - esac - - - name: Check update stream format - run: | - PLATFORM="${{ steps.platform.outputs.platform }}" - case "$PLATFORM" in - joomla) - if [ -f "updates.xml" ]; then - if command -v php &> /dev/null; then - php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; } - fi - echo "updates.xml valid" - fi - ;; - dolibarr) - [ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt" - ;; - esac - - - name: Validate Joomla language files - if: steps.platform.outputs.platform == 'joomla' - run: | - ERRORS=0 - WARNINGS=0 - - # Require both en-GB and en-US language directories - LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1) - if [ -z "$LANG_ROOT" ]; then - echo "No language/ directory found — skipping" - exit 0 - fi - - if [ ! -d "$LANG_ROOT/en-GB" ]; then - echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)" - ERRORS=$((ERRORS + 1)) - fi - if [ ! -d "$LANG_ROOT/en-US" ]; then - echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)" - ERRORS=$((ERRORS + 1)) - fi - - # Check that en-GB and en-US have matching .ini files - if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then - for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do - [ ! -f "$GB_INI" ] && continue - US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")" - if [ ! -f "$US_INI" ]; then - echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US" - ERRORS=$((ERRORS + 1)) - fi - done - for US_INI in "$LANG_ROOT/en-US"/*.ini; do - [ ! -f "$US_INI" ] && continue - GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")" - if [ ! -f "$GB_INI" ]; then - echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB" - ERRORS=$((ERRORS + 1)) - fi - done - fi - - # Find all .ini language files - INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null) - if [ -z "$INI_FILES" ]; then - echo "No .ini language files found" - [ "$ERRORS" -gt 0 ] && exit 1 - exit 0 - fi - - echo "Found $(echo "$INI_FILES" | wc -l) language file(s)" - - for FILE in $INI_FILES; do - FNAME=$(basename "$FILE") - LINENUM=0 - SEEN_KEYS="" - - while IFS= read -r line || [ -n "$line" ]; do - LINENUM=$((LINENUM + 1)) - - # Skip empty lines and comments - [ -z "$line" ] && continue - echo "$line" | grep -qE '^\s*;' && continue - echo "$line" | grep -qE '^\s*$' && continue - - # Must match KEY="VALUE" format - if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then - echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}" - ERRORS=$((ERRORS + 1)) - continue - fi - - # Extract key and check for duplicates - KEY=$(echo "$line" | sed 's/=.*//') - if echo "$SEEN_KEYS" | grep -qx "$KEY"; then - echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}" - ERRORS=$((ERRORS + 1)) - fi - SEEN_KEYS="${SEEN_KEYS} - ${KEY}" - done < "$FILE" - - echo " ${FILE}: checked ${LINENUM} lines" - done - - # Cross-check en-GB vs en-US key consistency - GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1) - US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1) - - if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then - for GB_FILE in "$GB_DIR"/*.ini; do - [ ! -f "$GB_FILE" ] && continue - FNAME=$(basename "$GB_FILE") - US_FILE="$US_DIR/$FNAME" - [ ! -f "$US_FILE" ] && continue - - GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort) - US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort) - - # Keys in en-GB but not en-US - MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS")) - if [ -n "$MISSING_US" ]; then - echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:" - echo "$MISSING_US" | while read -r k; do echo " - $k"; done - WARNINGS=$((WARNINGS + 1)) - fi - - # Keys in en-US but not en-GB - MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS")) - if [ -n "$MISSING_GB" ]; then - echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:" - echo "$MISSING_GB" | while read -r k; do echo " - $k"; done - WARNINGS=$((WARNINGS + 1)) - fi - done - fi - - { - echo "### Language File Validation" - echo "| Metric | Count |" - echo "|---|---|" - echo "| Files checked | $(echo "$INI_FILES" | wc -l) |" - echo "| Errors | ${ERRORS} |" - echo "| Warnings | ${WARNINGS} |" - } >> $GITHUB_STEP_SUMMARY - - if [ "$ERRORS" -gt 0 ]; then - echo "::error::Language validation failed with ${ERRORS} error(s)" - exit 1 - fi - echo "Language files: OK (${WARNINGS} warning(s))" - - - name: Check changelog has unreleased entry - run: | - if [ ! -f "CHANGELOG.md" ]; then - echo "::warning::No CHANGELOG.md found" - exit 0 - fi - # Check for content under [Unreleased] section - if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then - echo "::error::CHANGELOG.md missing [Unreleased] section" - exit 1 - fi - # Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased - UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true) - if [ "$UNRELEASED_CONTENT" -eq 0 ]; then - echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes." - echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY - echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY - echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY - exit 1 - fi - echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]" - - - name: Verify package source - run: | - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" - if [ ! -d "$SOURCE_DIR" ]; then - echo "::warning::No src/ or htdocs/ directory" - exit 0 - fi - FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l) - echo "Source: ${FILE_COUNT} files" - [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } - - # ── Pre-Release RC Build ───────────────────────────────────────────────── - pre-release: - name: Build RC Package - runs-on: ubuntu-latest - needs: [branch-policy, validate] - - steps: - - name: Trigger RC pre-release - env: - GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - REPO: ${{ github.repository }} - BRANCH: ${{ github.head_ref }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - run: | - curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" - echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY - echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY - - # ── Issue Reporter ────────────────────────────────────────────────────── - report-issues: - name: Report Issues - runs-on: ubuntu-latest - needs: [branch-policy, validate] - if: >- - always() && - needs.validate.result == 'failure' - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - sparse-checkout: automation/ci-issue-reporter.sh - sparse-checkout-cone-mode: false - - - name: "File issue for PR validation failure" - env: - GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - run: | - chmod +x automation/ci-issue-reporter.sh - ./automation/ci-issue-reporter.sh \ - --gate "PR Validation" \ - --workflow "PR Check" \ - --severity error \ - --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/universal/pr-check.yml.template +# VERSION: 09.23.00 +# BRIEF: PR gate — branch policy + code validation before merge + +name: "Universal: PR Check" + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + # ── Branch Policy ────────────────────────────────────────────────────── + branch-policy: + name: Branch Policy + runs-on: ubuntu-latest + steps: + - name: Check branch merge target + run: | + HEAD="${{ github.head_ref }}" + BASE="${{ github.base_ref }}" + + echo "PR: ${HEAD} → ${BASE}" + + ALLOWED=true + REASON="" + + case "$HEAD" in + feature/*|feat/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Feature branches must target 'dev', not '${BASE}'" + fi + ;; + fix/*|bugfix/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Fix branches must target 'dev', not '${BASE}'" + fi + ;; + patch/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then + ALLOWED=false + REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'" + fi + ;; + hotfix/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" + fi + ;; + rc) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="RC branch can only merge into 'main', not '${BASE}'" + fi + ;; + dev) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Dev branch can only merge into 'main', not '${BASE}'" + fi + ;; + esac + + if [ "$ALLOWED" = false ]; then + echo "::error::${REASON}" + echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "${REASON}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY + echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + echo "Branch policy: OK (${HEAD} → ${BASE})" + echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY + + # ── Secret Scanning ────────────────────────────────────────────────── + gitleaks: + name: Secret Scan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Gitleaks + run: | + GITLEAKS_VERSION="8.21.2" + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /usr/local/bin gitleaks + + - name: Scan PR commits for secrets + run: | + if gitleaks detect --source . --verbose \ + --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then + echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY + else + echo "::error::Potential secrets detected in PR commits" + exit 1 + fi + + # ── Code Validation ──────────────────────────────────────────────────── + validate: + name: Validate PR + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check for merge conflict markers + run: | + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + if [ -n "$CONFLICTS" ]; then + echo "::error::Merge conflict markers found in source files" + echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "No conflict markers found" + + - name: Detect platform + id: platform + run: | + # Read platform from XML manifest ( tag) or plain text fallback + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') + [ -z "$PLATFORM" ] && PLATFORM="generic" + echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" + + - name: Setup PHP + if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' + run: | + if ! command -v php &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1 + fi + + - name: PHP syntax check + if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0) + echo "PHP lint: ${ERRORS} error(s)" + [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; } + + - name: Joomla JEXEC guard check + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + # Skip vendor, node_modules, and index.html stub files + case "$file" in ./vendor/*|./node_modules/*) continue ;; esac + # Check first 10 lines for JEXEC or JPATH guard + if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then + echo "::error file=${file}::Missing JEXEC guard: ${file}" + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0) + if [ "$ERRORS" -gt 0 ]; then + echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard" + echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY + echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "JEXEC guard: OK" + + - name: Joomla directory listing protection + if: steps.platform.outputs.platform == 'joomla' + run: | + MISSING=0 + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && exit 0 + while IFS= read -r dir; do + if [ ! -f "${dir}/index.html" ]; then + echo "::warning::Missing index.html in ${dir} (directory listing protection)" + MISSING=$((MISSING + 1)) + fi + done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*") + if [ "$MISSING" -gt 0 ]; then + echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY + echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY + fi + echo "Directory protection: ${MISSING} missing (advisory)" + + - name: Joomla script file and asset checks + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && exit 0 + MANIFEST_DIR=$(dirname "$MANIFEST") + + # Check scriptfile exists if declared + SCRIPTFILE=$(sed -n 's/.*\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null) + if [ -n "$SCRIPTFILE" ]; then + if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then + echo "::error::Manifest declares ${SCRIPTFILE} but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}" + ERRORS=$((ERRORS + 1)) + else + echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)" + fi + fi + + # Require joomla.asset.json and validate it + ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$ASSET_JSON" ]; then + echo "::error::joomla.asset.json not found — Joomla asset system is required" + ERRORS=$((ERRORS + 1)) + else + if command -v php &> /dev/null; then + php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || { + echo "::error::joomla.asset.json is not valid JSON" + ERRORS=$((ERRORS + 1)) + } + fi + echo "joomla.asset.json: valid" + fi + + # Validate all XML files in src/ are well-formed + XML_ERRORS=0 + if command -v php &> /dev/null; then + while IFS= read -r -d '' xmlfile; do + if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then + XML_ERRORS=$((XML_ERRORS + 1)) + fi + done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0) + fi + if [ "$XML_ERRORS" -gt 0 ]; then + echo "::error::${XML_ERRORS} XML file(s) are malformed" + ERRORS=$((ERRORS + 1)) + else + echo "XML well-formedness: OK" + fi + + [ "$ERRORS" -gt 0 ] && exit 1 + echo "Joomla asset checks: OK" + + - name: Validate platform manifest + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + case "$PLATFORM" in + joomla) + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "::warning::No Joomla manifest found (WaaS site)" + exit 0 + fi + echo "Manifest: ${MANIFEST}" + if command -v php &> /dev/null; then + php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; } + fi + for ELEMENT in name version description; do + grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; } + done + # Block legacy raw/branch update server URLs on MokoGitea + RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true) + if [ -n "$RAW_URLS" ]; then + echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" + echo "$RAW_URLS" + exit 1 + fi + echo "Joomla manifest valid" + ;; + dolibarr) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) + if [ -z "$MOD_FILE" ]; then + echo "::error::No mod*.class.php found" + exit 1 + fi + echo "Dolibarr module: ${MOD_FILE}" + ;; + *) + echo "Generic platform — no manifest validation" + ;; + esac + + - name: Check update stream format + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + case "$PLATFORM" in + joomla) + if [ -f "updates.xml" ]; then + if command -v php &> /dev/null; then + php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; } + fi + echo "updates.xml valid" + fi + ;; + dolibarr) + [ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt" + ;; + esac + + - name: Validate Joomla language files + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + WARNINGS=0 + + # Require both en-GB and en-US language directories + LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$LANG_ROOT" ]; then + echo "No language/ directory found — skipping" + exit 0 + fi + + if [ ! -d "$LANG_ROOT/en-GB" ]; then + echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)" + ERRORS=$((ERRORS + 1)) + fi + if [ ! -d "$LANG_ROOT/en-US" ]; then + echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)" + ERRORS=$((ERRORS + 1)) + fi + + # Check that en-GB and en-US have matching .ini files + if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then + for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do + [ ! -f "$GB_INI" ] && continue + US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")" + if [ ! -f "$US_INI" ]; then + echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US" + ERRORS=$((ERRORS + 1)) + fi + done + for US_INI in "$LANG_ROOT/en-US"/*.ini; do + [ ! -f "$US_INI" ] && continue + GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")" + if [ ! -f "$GB_INI" ]; then + echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB" + ERRORS=$((ERRORS + 1)) + fi + done + fi + + # Find all .ini language files + INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null) + if [ -z "$INI_FILES" ]; then + echo "No .ini language files found" + [ "$ERRORS" -gt 0 ] && exit 1 + exit 0 + fi + + echo "Found $(echo "$INI_FILES" | wc -l) language file(s)" + + for FILE in $INI_FILES; do + FNAME=$(basename "$FILE") + LINENUM=0 + SEEN_KEYS="" + + while IFS= read -r line || [ -n "$line" ]; do + LINENUM=$((LINENUM + 1)) + + # Skip empty lines and comments + [ -z "$line" ] && continue + echo "$line" | grep -qE '^\s*;' && continue + echo "$line" | grep -qE '^\s*$' && continue + + # Must match KEY="VALUE" format + if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then + echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}" + ERRORS=$((ERRORS + 1)) + continue + fi + + # Extract key and check for duplicates + KEY=$(echo "$line" | sed 's/=.*//') + if echo "$SEEN_KEYS" | grep -qx "$KEY"; then + echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}" + ERRORS=$((ERRORS + 1)) + fi + SEEN_KEYS="${SEEN_KEYS} + ${KEY}" + done < "$FILE" + + echo " ${FILE}: checked ${LINENUM} lines" + done + + # Cross-check en-GB vs en-US key consistency + GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1) + US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1) + + if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then + for GB_FILE in "$GB_DIR"/*.ini; do + [ ! -f "$GB_FILE" ] && continue + FNAME=$(basename "$GB_FILE") + US_FILE="$US_DIR/$FNAME" + [ ! -f "$US_FILE" ] && continue + + GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort) + US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort) + + # Keys in en-GB but not en-US + MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_US" ]; then + echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:" + echo "$MISSING_US" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + + # Keys in en-US but not en-GB + MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_GB" ]; then + echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:" + echo "$MISSING_GB" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + done + fi + + { + echo "### Language File Validation" + echo "| Metric | Count |" + echo "|---|---|" + echo "| Files checked | $(echo "$INI_FILES" | wc -l) |" + echo "| Errors | ${ERRORS} |" + echo "| Warnings | ${WARNINGS} |" + } >> $GITHUB_STEP_SUMMARY + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Language validation failed with ${ERRORS} error(s)" + exit 1 + fi + echo "Language files: OK (${WARNINGS} warning(s))" + + - name: Check changelog has unreleased entry + run: | + if [ ! -f "CHANGELOG.md" ]; then + echo "::warning::No CHANGELOG.md found" + exit 0 + fi + # Check for content under [Unreleased] section + if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md missing [Unreleased] section" + exit 1 + fi + # Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased + UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true) + if [ "$UNRELEASED_CONTENT" -eq 0 ]; then + echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes." + echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY + echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY + echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]" + + - name: Verify package source + run: | + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + if [ ! -d "$SOURCE_DIR" ]; then + echo "::warning::No src/ or htdocs/ directory" + exit 0 + fi + FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l) + echo "Source: ${FILE_COUNT} files" + [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } + + # ── Pre-Release RC Build ───────────────────────────────────────────────── + pre-release: + name: Build RC Package + runs-on: ubuntu-latest + needs: [branch-policy, validate] + + steps: + - name: Trigger RC pre-release + env: + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.head_ref }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" + echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY + echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY + + # ── Issue Reporter ────────────────────────────────────────────────────── + report-issues: + name: Report Issues + runs-on: ubuntu-latest + needs: [branch-policy, validate] + if: >- + always() && + needs.validate.result == 'failure' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issue for PR validation failure" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + ./automation/ci-issue-reporter.sh \ + --gate "PR Validation" \ + --workflow "PR Check" \ + --severity error \ + --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml index 154f77d..6a25f5b 100644 --- a/.mokogitea/workflows/repo-health.yml +++ b/.mokogitea/workflows/repo-health.yml @@ -1,712 +1,712 @@ -# ============================================================================ -# Copyright (C) 2025 Moko Consulting -# -# This file is part of a Moko Consulting project. -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: mokocli.Validation -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli -# 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. -# ============================================================================ - -name: "Generic: Repo Health" - -defaults: - run: - shell: bash - -on: - workflow_dispatch: - inputs: - profile: - description: 'Validation profile: all, scripts, or repo' - required: true - default: all - type: choice - options: - - all - - scripts - - repo - pull_request: - branches: - - main - -permissions: - contents: read - -env: - # 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_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/ - REPO_DISALLOWED_DIRS: - REPO_DISALLOWED_FILES: TODO.md,todo.md - - # Extended checks toggles - EXTENDED_CHECKS: "true" - - # File / directory variables - DOCS_INDEX: docs/docs-index.md - SCRIPT_DIR: scripts - WORKFLOWS_DIR: .mokogitea/workflows - SHELLCHECK_PATTERN: '*.sh' - SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml' - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - access_check: - name: Access control - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - - outputs: - allowed: ${{ steps.perm.outputs.allowed }} - permission: ${{ steps.perm.outputs.permission }} - - steps: - - name: Check actor permission (admin only) - id: perm - env: - TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }} - REPO: ${{ github.repository }} - ACTOR: ${{ github.actor }} - run: | - set -euo pipefail - ALLOWED=false - PERMISSION=unknown - METHOD="" - - # Hardcoded authorized users — always allowed - case "$ACTOR" in - jmiller|gitea-actions[bot]) - ALLOWED=true - PERMISSION=admin - METHOD="hardcoded allowlist" - ;; - *) - # Detect platform and check permissions via API - API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}" - RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}') - PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown") - if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then - ALLOWED=true - fi - METHOD="collaborator API" - ;; - esac - - echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT" - echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT" - - { - echo "## Access Authorization" - echo "" - echo "| Field | Value |" - echo "|-------|-------|" - echo "| **Actor** | \`${ACTOR}\` |" - echo "| **Repository** | \`${REPO}\` |" - echo "| **Permission** | \`${PERMISSION}\` |" - echo "| **Method** | ${METHOD} |" - echo "| **Authorized** | ${ALLOWED} |" - echo "" - if [ "$ALLOWED" = "true" ]; then - echo "${ACTOR} authorized (${METHOD})" - else - echo "${ACTOR} is NOT authorized. Requires admin or maintain role." - fi - } >> "${GITHUB_STEP_SUMMARY}" - - - name: Deny execution when not permitted - if: ${{ steps.perm.outputs.allowed != 'true' }} - run: | - set -euo pipefail - printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}" - exit 1 - - scripts_governance: - name: Scripts governance - needs: access_check - if: ${{ needs.access_check.outputs.allowed == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - fetch-depth: 0 - - - name: Scripts folder checks - env: - PROFILE_RAW: ${{ github.event.inputs.profile }} - run: | - set -euo pipefail - - profile="${PROFILE_RAW:-all}" - case "${profile}" in - all|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac - - if [ "${profile}" = 'repo' ]; then - { - printf '%s\n' '### Scripts governance' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' 'Status: SKIPPED' - printf '%s\n' 'Reason: profile excludes scripts governance' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 0 - fi - - if [ ! -d "${SCRIPT_DIR}" ]; then - { - printf '%s\n' '### Scripts governance' - printf '%s\n' 'Status: OK (advisory)' - printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - 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 allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}" - - missing_dirs=() - unapproved_dirs=() - - for d in "${required_dirs[@]}"; do - req="${d%/}" - [ ! -d "${req}" ] && missing_dirs+=("${req}/") - done - - while IFS= read -r d; do - allowed=false - for a in "${allowed_dirs[@]}"; do - a_norm="${a%/}" - [ "${d%/}" = "${a_norm}" ] && allowed=true - done - [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/") - done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') - - { - printf '%s\n' '### Scripts governance' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' '| Area | Status | Notes |' - printf '%s\n' '|---|---|---|' - - if [ "${#missing_dirs[@]}" -gt 0 ]; then - printf '%s\n' '| Required directories | Warning | Missing required subfolders |' - else - printf '%s\n' '| Required directories | OK | All required subfolders present |' - fi - - if [ "${#unapproved_dirs[@]}" -gt 0 ]; then - printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |' - else - printf '%s\n' '| Directory policy | OK | No unapproved directories |' - fi - - printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |' - printf '\n' - - if [ "${#missing_dirs[@]}" -gt 0 ]; then - printf '%s\n' 'Missing required script directories:' - for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done - printf '\n' - else - printf '%s\n' 'Missing required script directories: none.' - printf '\n' - fi - - if [ "${#unapproved_dirs[@]}" -gt 0 ]; then - printf '%s\n' 'Unapproved script directories detected:' - for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done - printf '\n' - else - printf '%s\n' 'Unapproved script directories detected: none.' - printf '\n' - fi - - printf '%s\n' 'Scripts governance completed in advisory mode.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - - repo_health: - name: Repository health - 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: Repository health checks - env: - PROFILE_RAW: ${{ github.event.inputs.profile }} - run: | - set -euo pipefail - - profile="${PROFILE_RAW:-all}" - case "${profile}" in - all|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac - - if [ "${profile}" = 'scripts' ]; then - { - printf '%s\n' '### Repository health' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' 'Status: SKIPPED' - printf '%s\n' 'Reason: profile excludes repository health' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - 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="" - 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 - - for item in "${required_artifacts[@]}"; do - if printf '%s' "${item}" | grep -q '/$'; then - d="${item%/}" - [ ! -d "${d}" ] && missing_required+=("${item}") - else - [ ! -f "${item}" ] && missing_required+=("${item}") - fi - done - - for f in "${optional_files[@]}"; do - if printf '%s' "${f}" | grep -q '/$'; then - d="${f%/}" - [ ! -d "${d}" ] && missing_optional+=("${f}") - else - [ ! -f "${f}" ] && missing_optional+=("${f}") - fi - done - - for d in "${disallowed_dirs[@]}"; do - d_norm="${d%/}" - [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)") - done - - for f in "${disallowed_files[@]}"; do - [ -f "${f}" ] && missing_required+=("${f} (disallowed)") - done - - git fetch origin --prune - - dev_paths=() - dev_branches=() - - while IFS= read -r b; do - name="${b#origin/}" - if [ "${name}" = 'dev' ]; then - dev_branches+=("${name}") - else - dev_paths+=("${name}") - 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") - fi - - content_warnings=() - - if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then - content_warnings+=("CHANGELOG.md missing '# Changelog' header") - fi - - if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then - content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)") - fi - - if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then - content_warnings+=("LICENSE does not look like a GPL text") - fi - - if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then - content_warnings+=("README.md missing expected brand keyword") - fi - - export PROFILE_RAW="${profile}" - export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")" - 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[@]}") - - { - printf '%s\n' '### Repository health' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' '| Metric | Value |' - printf '%s\n' '|---|---|' - printf '%s\n' "| Missing required | ${#missing_required[@]} |" - printf '%s\n' "| Missing optional | ${#missing_optional[@]} |" - printf '%s\n' "| Content warnings | ${#content_warnings[@]} |" - printf '\n' - - printf '%s\n' '### Guardrails report (JSON)' - printf '%s\n' '```json' - printf '%s\n' "${report_json}" - printf '%s\n' '```' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - - if [ "${#missing_required[@]}" -gt 0 ]; then - { - printf '%s\n' '### Missing required repo artifacts' - for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done - printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 1 - fi - - if [ "${#missing_optional[@]}" -gt 0 ]; then - { - printf '%s\n' '### Missing optional repo artifacts' - for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - - if [ "${#content_warnings[@]}" -gt 0 ]; then - { - printf '%s\n' '### Repo content warnings' - for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - - # -- Joomla-specific checks -- - joomla_findings=() - - MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '/dev/null | head -1 || true)" - if [ -z "${MANIFEST}" ]; then - joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)") - else - if ! grep -qP '' "${MANIFEST}"; then - joomla_findings+=("XML manifest: tag missing") - fi - if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then - joomla_findings+=("XML manifest: type attribute missing or invalid") - fi - if ! grep -qP '' "${MANIFEST}"; then - joomla_findings+=("XML manifest: tag missing") - fi - if ! grep -qP '' "${MANIFEST}"; then - joomla_findings+=("XML manifest: tag missing") - fi - if ! grep -qP ' missing (required for Joomla 5+)") - fi - fi - - INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)" - if [ "${INI_COUNT}" -eq 0 ]; then - joomla_findings+=("No .ini language files found") - fi - - if [ ! -f 'updates.xml' ]; then - 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 - - if [ "${#joomla_findings[@]}" -gt 0 ]; then - { - printf '%s\n' '### Joomla extension checks' - printf '%s\n' '| Check | Status |' - printf '%s\n' '|---|---|' - for f in "${joomla_findings[@]}"; do - printf '%s\n' "| ${f} | Warning |" - done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - else - { - printf '%s\n' '### Joomla extension checks' - printf '%s\n' 'All Joomla-specific checks passed.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - - extended_enabled="${EXTENDED_CHECKS:-true}" - extended_findings=() - - if [ "${extended_enabled}" = 'true' ]; then - if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then - : - else - extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)") - fi - - if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then - bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)" - if [ -n "${bad_refs}" ]; then - extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt") - { - printf '%s\n' '### Workflow pinning advisory' - printf '%s\n' 'Found uses: entries pinned to main/master:' - printf '%s\n' '```' - printf '%s\n' "${bad_refs}" - printf '%s\n' '```' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - 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}" - 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 - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - fi - - if [ -d "${SCRIPT_DIR}" ]; then - if ! command -v shellcheck >/dev/null 2>&1; then - sudo apt-get update -qq - sudo apt-get install -y shellcheck >/dev/null - fi - - sc_out='' - while IFS= read -r shf; do - [ -z "${shf}" ] && continue - out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)" - if [ -n "${out_one}" ]; then - sc_out="${sc_out}${out_one}\n" - fi - done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort) - - if [ -n "${sc_out}" ]; then - extended_findings+=("ShellCheck warnings detected (advisory)") - sc_head="$(printf '%s' "${sc_out}" | head -n 200)" - { - printf '%s\n' '### ShellCheck (advisory)' - printf '%s\n' '```' - printf '%s\n' "${sc_head}" - printf '%s\n' '```' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - fi - - spdx_missing=() - IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}" - spdx_args=() - for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done - - while IFS= read -r f; do - [ -z "${f}" ] && continue - if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then - spdx_missing+=("${f}") - fi - done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true) - - if [ "${#spdx_missing[@]}" -gt 0 ]; then - extended_findings+=("SPDX header missing in some tracked files (advisory)") - { - printf '%s\n' '### SPDX header advisory' - printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):' - for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - - stale_cutoff_days=180 - stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)" - if [ -n "${stale_branches}" ]; then - extended_findings+=("Stale remote branches detected (advisory)") - { - printf '%s\n' '### Git hygiene advisory' - printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):" - while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}" - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - fi - - { - printf '%s\n' '### Guardrails coverage matrix' - 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' '| 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 |' - if [ "${extended_enabled}" = 'true' ]; then - if [ "${#extended_findings[@]}" -gt 0 ]; then - printf '%s\n' '| Extended checks | Warning | See extended findings below |' - else - printf '%s\n' '| Extended checks | OK | No findings |' - fi - else - printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |' - fi - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - - if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then - { - printf '%s\n' '### Extended findings (advisory)' - for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - 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." +# ============================================================================ +# Copyright (C) 2025 Moko Consulting +# +# This file is part of a Moko Consulting project. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: mokocli.Validation +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli +# 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. +# ============================================================================ + +name: "Generic: Repo Health" + +defaults: + run: + shell: bash + +on: + workflow_dispatch: + inputs: + profile: + description: 'Validation profile: all, scripts, or repo' + required: true + default: all + type: choice + options: + - all + - scripts + - repo + pull_request: + branches: + - main + +permissions: + contents: read + +env: + # 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_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/ + REPO_DISALLOWED_DIRS: + REPO_DISALLOWED_FILES: TODO.md,todo.md + + # Extended checks toggles + EXTENDED_CHECKS: "true" + + # File / directory variables + DOCS_INDEX: docs/docs-index.md + SCRIPT_DIR: scripts + WORKFLOWS_DIR: .mokogitea/workflows + SHELLCHECK_PATTERN: '*.sh' + SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml' + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + access_check: + name: Access control + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + outputs: + allowed: ${{ steps.perm.outputs.allowed }} + permission: ${{ steps.perm.outputs.permission }} + + steps: + - name: Check actor permission (admin only) + id: perm + env: + TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }} + REPO: ${{ github.repository }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + ALLOWED=false + PERMISSION=unknown + METHOD="" + + # Hardcoded authorized users — always allowed + case "$ACTOR" in + jmiller|gitea-actions[bot]) + ALLOWED=true + PERMISSION=admin + METHOD="hardcoded allowlist" + ;; + *) + # Detect platform and check permissions via API + API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}" + RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}') + PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown") + if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then + ALLOWED=true + fi + METHOD="collaborator API" + ;; + esac + + echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT" + echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT" + + { + echo "## Access Authorization" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Actor** | \`${ACTOR}\` |" + echo "| **Repository** | \`${REPO}\` |" + echo "| **Permission** | \`${PERMISSION}\` |" + echo "| **Method** | ${METHOD} |" + echo "| **Authorized** | ${ALLOWED} |" + echo "" + if [ "$ALLOWED" = "true" ]; then + echo "${ACTOR} authorized (${METHOD})" + else + echo "${ACTOR} is NOT authorized. Requires admin or maintain role." + fi + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Deny execution when not permitted + if: ${{ steps.perm.outputs.allowed != 'true' }} + run: | + set -euo pipefail + printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}" + exit 1 + + scripts_governance: + name: Scripts governance + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Scripts folder checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'repo' ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes scripts governance' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + if [ ! -d "${SCRIPT_DIR}" ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' 'Status: OK (advisory)' + printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + 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 allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}" + + missing_dirs=() + unapproved_dirs=() + + for d in "${required_dirs[@]}"; do + req="${d%/}" + [ ! -d "${req}" ] && missing_dirs+=("${req}/") + done + + while IFS= read -r d; do + allowed=false + for a in "${allowed_dirs[@]}"; do + a_norm="${a%/}" + [ "${d%/}" = "${a_norm}" ] && allowed=true + done + [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/") + done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') + + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Area | Status | Notes |' + printf '%s\n' '|---|---|---|' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Required directories | Warning | Missing required subfolders |' + else + printf '%s\n' '| Required directories | OK | All required subfolders present |' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |' + else + printf '%s\n' '| Directory policy | OK | No unapproved directories |' + fi + + printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |' + printf '\n' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Missing required script directories:' + for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Missing required script directories: none.' + printf '\n' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Unapproved script directories detected:' + for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Unapproved script directories detected: none.' + printf '\n' + fi + + printf '%s\n' 'Scripts governance completed in advisory mode.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + repo_health: + name: Repository health + 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: Repository health checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'scripts' ]; then + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes repository health' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + 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="" + 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 + + for item in "${required_artifacts[@]}"; do + if printf '%s' "${item}" | grep -q '/$'; then + d="${item%/}" + [ ! -d "${d}" ] && missing_required+=("${item}") + else + [ ! -f "${item}" ] && missing_required+=("${item}") + fi + done + + for f in "${optional_files[@]}"; do + if printf '%s' "${f}" | grep -q '/$'; then + d="${f%/}" + [ ! -d "${d}" ] && missing_optional+=("${f}") + else + [ ! -f "${f}" ] && missing_optional+=("${f}") + fi + done + + for d in "${disallowed_dirs[@]}"; do + d_norm="${d%/}" + [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)") + done + + for f in "${disallowed_files[@]}"; do + [ -f "${f}" ] && missing_required+=("${f} (disallowed)") + done + + git fetch origin --prune + + dev_paths=() + dev_branches=() + + while IFS= read -r b; do + name="${b#origin/}" + if [ "${name}" = 'dev' ]; then + dev_branches+=("${name}") + else + dev_paths+=("${name}") + 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") + fi + + content_warnings=() + + if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md missing '# Changelog' header") + fi + + if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)") + fi + + if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then + content_warnings+=("LICENSE does not look like a GPL text") + fi + + if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then + content_warnings+=("README.md missing expected brand keyword") + fi + + export PROFILE_RAW="${profile}" + export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")" + 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[@]}") + + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Metric | Value |' + printf '%s\n' '|---|---|' + printf '%s\n' "| Missing required | ${#missing_required[@]} |" + printf '%s\n' "| Missing optional | ${#missing_optional[@]} |" + printf '%s\n' "| Content warnings | ${#content_warnings[@]} |" + printf '\n' + + printf '%s\n' '### Guardrails report (JSON)' + printf '%s\n' '```json' + printf '%s\n' "${report_json}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${#missing_required[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing required repo artifacts' + for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done + printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + if [ "${#missing_optional[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing optional repo artifacts' + for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + if [ "${#content_warnings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Repo content warnings' + for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + # -- Joomla-specific checks -- + joomla_findings=() + + MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '/dev/null | head -1 || true)" + if [ -z "${MANIFEST}" ]; then + joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)") + else + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then + joomla_findings+=("XML manifest: type attribute missing or invalid") + fi + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP ' missing (required for Joomla 5+)") + fi + fi + + INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)" + if [ "${INI_COUNT}" -eq 0 ]; then + joomla_findings+=("No .ini language files found") + fi + + if [ ! -f 'updates.xml' ]; then + 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 + + if [ "${#joomla_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' '| Check | Status |' + printf '%s\n' '|---|---|' + for f in "${joomla_findings[@]}"; do + printf '%s\n' "| ${f} | Warning |" + done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + else + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' 'All Joomla-specific checks passed.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + extended_enabled="${EXTENDED_CHECKS:-true}" + extended_findings=() + + if [ "${extended_enabled}" = 'true' ]; then + if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then + : + else + extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)") + fi + + if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then + bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)" + if [ -n "${bad_refs}" ]; then + extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt") + { + printf '%s\n' '### Workflow pinning advisory' + printf '%s\n' 'Found uses: entries pinned to main/master:' + printf '%s\n' '```' + printf '%s\n' "${bad_refs}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + 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}" + 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 + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + if [ -d "${SCRIPT_DIR}" ]; then + if ! command -v shellcheck >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install -y shellcheck >/dev/null + fi + + sc_out='' + while IFS= read -r shf; do + [ -z "${shf}" ] && continue + out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)" + if [ -n "${out_one}" ]; then + sc_out="${sc_out}${out_one}\n" + fi + done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort) + + if [ -n "${sc_out}" ]; then + extended_findings+=("ShellCheck warnings detected (advisory)") + sc_head="$(printf '%s' "${sc_out}" | head -n 200)" + { + printf '%s\n' '### ShellCheck (advisory)' + printf '%s\n' '```' + printf '%s\n' "${sc_head}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + spdx_missing=() + IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}" + spdx_args=() + for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done + + while IFS= read -r f; do + [ -z "${f}" ] && continue + if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then + spdx_missing+=("${f}") + fi + done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true) + + if [ "${#spdx_missing[@]}" -gt 0 ]; then + extended_findings+=("SPDX header missing in some tracked files (advisory)") + { + printf '%s\n' '### SPDX header advisory' + printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):' + for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + stale_cutoff_days=180 + stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)" + if [ -n "${stale_branches}" ]; then + extended_findings+=("Stale remote branches detected (advisory)") + { + printf '%s\n' '### Git hygiene advisory' + printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):" + while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + { + printf '%s\n' '### Guardrails coverage matrix' + 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' '| 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 |' + if [ "${extended_enabled}" = 'true' ]; then + if [ "${#extended_findings[@]}" -gt 0 ]; then + printf '%s\n' '| Extended checks | Warning | See extended findings below |' + else + printf '%s\n' '| Extended checks | OK | No findings |' + fi + else + printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |' + fi + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Extended findings (advisory)' + for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + 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." diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml index 789325a..714d407 100644 --- a/.mokogitea/workflows/security-audit.yml +++ b/.mokogitea/workflows/security-audit.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: MokoStandards.Security -# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards +# INGROUP: moko-platform.Security +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/security-audit.yml # VERSION: 01.00.00 # BRIEF: Dependency vulnerability scanning for composer and npm packages @@ -80,3 +80,19 @@ jobs: -H "Priority: high" \ -d "Security audit found vulnerabilities. Review dependency updates." \ "${NTFY_URL}/${NTFY_TOPIC}" || true + + + - name: Joomla version audit + if: always() + run: | + if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then + echo "$JOOMLA_SITES" > /tmp/sites.json + php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true + echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY + rm -f /tmp/sites.json + else + echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)" + fi + env: + JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }} + diff --git a/CHANGELOG.md b/CHANGELOG.md index ce5bf70..8a55abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,27 +1,56 @@ # Changelog -## [Unreleased] + - - - -All notable changes to MokoOpenGraph will be documented in this file. +All notable changes to MokoSuiteOpenGraph will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [01.01.00] --- 2026-06-19 +## [Unreleased] -### Removed -- Removed deploy-manual.yml workflow — switching to Joomla update server method for extension distribution +### Security +- Fix JSON-LD XSS vulnerability via `` 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 -- 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 +- 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 - Default fallback image configuration -- Admin tag manager component for viewing all OG records -- Facebook App ID support -- Database table `#__mokoog_tags` for storing custom OG data +- Admin tag manager component with filtering, search, and pagination +- Facebook App ID and Telegram channel support +- Database table `#__mokoog_tags` with multilingual unique key + +### Changed +- Consolidated article DB queries into single cached lookup — 5 queries reduced to 1 (#38) +- Dynamic `og:image:width`/`og:image:height` from actual image dimensions instead of hardcoded (#39) +- Replace GD `@` error suppression with `Log::add()` warnings (#49) +- TagTable::check() validates og_type, field lengths, canonical_url, robots directives (#43) +- CSV import/export now includes language column for multilingual support (#52) +- Batch process limit capped at 200 per request (#42) +- Canonical URL replacement uses public `getHeadData()`/`setHeadData()` API (#39) +- Language-aware queries on `loadOgDataByType()` and `loadOgDataByMenu()` (#47) + +### Removed +- Removed dead ContentType adapters (K2, VirtueMart, HikaShop) — not targeting these platforms (#36) +- Removed `` from package manifest — managed externally (#44) +- Removed deploy-manual.yml workflow diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 1d6447f..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,78 +0,0 @@ -# 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 `` and `` 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 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2d63a38 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,28 @@ +# 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf60cc5..e566bdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,161 +1,34 @@ -# Contributing to Moko Consulting Projects - -Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy. - -## Branching Workflow - -``` -feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main -``` - -### Step by step - -1. **Create a feature branch** from `dev`: - ```bash - git checkout dev && git pull - git checkout -b feature/my-change - ``` - -2. **Work and commit** on your feature branch. Push to origin. - -3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it. - -4. **When ready for release**, open a **draft PR**: `dev` → `main`. - - This automatically renames the source branch to `rc` (release candidate) - - An RC pre-release is built and uploaded - -5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage: - - Rename `dev` to `alpha` for early testing → alpha pre-release is built - - Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built - - When the draft PR is created, the branch is renamed to `rc` - -6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`. - -7. **Merging to main** triggers the stable release pipeline: - - Minor version bump (e.g., `02.09.xx` → `02.10.00`) - - Stability suffix stripped (clean version) - - Gitea release created with ZIP/tar.gz packages - - `updates.xml` updated (Joomla extensions) - - `dev` branch recreated from `main` - -### Branch summary - -| Branch | Purpose | Created by | -|--------|---------|-----------| -| `feature/*` | New features and fixes | Developer | -| `dev` | Integration branch | Auto-recreated after release | -| `alpha` | Alpha pre-release testing | Manual rename from `dev` | -| `beta` | Beta pre-release testing | Manual rename from `alpha` | -| `rc` | Release candidate | Auto-renamed on draft PR to main | -| `main` | Stable releases | Protected, merge only | -| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI | - -### Protected branches - -| Branch | Direct push | Merge via | -|--------|------------|-----------| -| `main` | Blocked (CI bot whitelisted) | PR merge only | -| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* | -| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR | -| `alpha` | Blocked (CI bot whitelisted) | Manual rename | -| `beta` | Blocked (CI bot whitelisted) | Manual rename | -| `feature/*` | Open | N/A (source branch) | - -## Version Policy - -### Format - -All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded: - -- **XX** — Major version (breaking changes) -- **YY** — Minor version (new features, bumped on release to main) -- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches) - -Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major. - -### Stability suffixes - -Each branch appends a suffix to indicate stability: - -| Branch | Suffix | Example | -|--------|--------|---------| -| `main` | (none) | `02.09.00` | -| `dev` | `-dev` | `02.09.01-dev` | -| `feature/*` | `-dev` | `02.09.01-dev` | -| `alpha` | `-alpha` | `02.09.01-alpha` | -| `beta` | `-beta` | `02.09.01-beta` | -| `rc` | `-rc` | `02.09.01-rc` | - -### Auto version bump - -On every push to `dev`, `feature/*`, or `patch/*`: - -1. Patch version incremented -2. Stability suffix `-dev` applied -3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.) -4. Commit created with `[skip ci]` to avoid loops - -### Release version flow - -Version bumps happen at specific release events: - -| Event | Bump | Example | -|-------|------|---------| -| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` | -| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` | -| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) | -| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` | - -### Release stream copies - -When a higher-stability release is published, copies are created for all lesser streams with the same base version: - -- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta` -- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc` - -This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed). - -### Version files - -The version tools update all files containing version stamps: - -- `.mokogitea/manifest.xml` (canonical source) -- Joomla XML manifests (`` tag) -- `README.md`, `CHANGELOG.md` (`VERSION:` pattern) -- `package.json`, `pyproject.toml` -- Any text file with a `VERSION: XX.YY.ZZ` label - -Files synced from other repos (with a `# REPO:` header) are not touched. - -## Code Standards - -- **PHP**: PSR-12, tabs for indentation -- **Copyright**: all files must include the Moko Consulting copyright header -- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo) -- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names - -## Commit Messages - -Use conventional commit format: - -``` -type(scope): short description - -Optional body with context. - -Authored-by: Moko Consulting -``` - -Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci` - -Special flags in commit messages: -- `[skip ci]` — skip all CI workflows -- `[skip bump]` — skip auto version bump only - -## Reporting Issues - -Use the repository's issue tracker with the appropriate template. - ---- - -*Moko Consulting * +# 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. diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..a4d3679 --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,318 @@ +# 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 | `_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 diff --git a/Makefile b/Makefile index 67abb20..43340a6 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # Copyright (C) 2026 Moko Consulting # SPDX-License-Identifier: GPL-3.0-or-later # -# MokoOpenGraph — Open Graph & social sharing meta tag management +# MokoJoomOpenGraph — Open Graph & social sharing meta tag management # ============================================================================== # CONFIGURATION - Customize these for your extension diff --git a/README.md b/README.md index 4cfcdce..3224b68 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,69 @@ -# MokoOpenGraph +# MokoSuiteOpenGraph - + Open Graph, Twitter Card, and social sharing meta tag management for Joomla 4/5/6. ## Overview -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. +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. ## Features -- **Open Graph tags** — `og:title`, `og:description`, `og:image`, `og:url`, `og:type`, `og:site_name` +### Social Meta Tags +- **Open Graph tags** — `og:title`, `og:description`, `og:image`, `og:url`, `og:type`, `og:site_name`, `og:locale` - **Twitter/X Cards** — Summary and Summary with Large Image card types -- **Per-article control** — Custom OG fields in the article editor +- **LinkedIn** — `article:published_time`, `article:modified_time`, `article:author` +- **Discord** — Custom embed color via `theme-color` meta tag +- **Telegram** — `telegram:channel` for link previews +- **Facebook** — `fb:app_id` support, `og:image:width`/`og:image:height` for instant previews + +### Content Management +- **Per-article control** — Custom OG fields tab in the article editor - **Per-menu-item control** — Custom OG fields in the menu item editor -- **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 +- **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 `` tag per page +- **Meta description** — Per-page meta description control +- **Robots directive** — Per-page noindex/nofollow settings +- **Canonical URL** — Custom canonical URL overrides +- **JSON-LD structured data** — Article, Product, WebPage, BreadcrumbList, Organization schemas + +### Admin Tools +- **Tag manager dashboard** — View and manage all OG records centrally +- **Batch generation** — Auto-generate OG tags for all existing articles +- **CSV import/export** — Bulk manage OG data via CSV files +- **SEO health badges** — Visual indicators for missing descriptions, long titles, noindex +- **Debug links** — Quick links to Facebook Debugger, LinkedIn Inspector, Google Rich Results +- **Live preview** — Real-time Facebook and Twitter/X card preview in the editor + +### Developer Features +- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`) +- **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta +- **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags +- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630 ## Installation -1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoOpenGraph/releases) +1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/releases) 2. In Joomla Administrator → Extensions → Install → Upload Package File -3. The system plugin is enabled automatically on install +3. All plugins are enabled automatically on install ## Configuration -Navigate to **Extensions → Plugins → System - MokoOpenGraph** to configure: +Navigate to **Extensions → Plugins → System - MokoSuiteOpenGraph** to configure: - Site name override +- Default OG title and description (site-wide fallback) - Default fallback image - Twitter Card type and @username - Facebook App ID -- Auto-generation behavior -- Description length limit +- Discord embed color +- Telegram channel +- Auto-generation, image resize, JSON-LD, and description length settings ## License diff --git a/composer.json b/composer.json index 132b2d7..c21717f 100644 --- a/composer.json +++ b/composer.json @@ -17,8 +17,10 @@ "require-dev": { "squizlabs/php_codesniffer": "^3.7", "phpstan/phpstan": "^1.10", - "joomla/coding-standards": "^4.0" + "joomla/coding-standards": "^3.0" }, + "minimum-stability": "alpha", + "prefer-stable": true, "config": { "sort-packages": true } diff --git a/source/index.html b/source/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/language/en-GB/index.html b/source/language/en-GB/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/language/en-GB/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/language/en-GB/pkg_mokoog.sys.ini b/source/language/en-GB/pkg_mokoog.sys.ini similarity index 65% rename from src/language/en-GB/pkg_mokoog.sys.ini rename to source/language/en-GB/pkg_mokoog.sys.ini index 76e8e3d..47aa587 100644 --- a/src/language/en-GB/pkg_mokoog.sys.ini +++ b/source/language/en-GB/pkg_mokoog.sys.ini @@ -1,7 +1,7 @@ -; MokoOpenGraph - Package System Language File +; MokoJoomOpenGraph - Package System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PKG_MOKOOG="MokoOpenGraph" +PKG_MOKOOG="MokoJoomOpenGraph" PKG_MOKOOG_DESCRIPTION="Complete Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Control how every page appears when shared on Facebook, Twitter/X, LinkedIn, WhatsApp, and more." -PKG_MOKOOG_PHP_VERSION_ERROR="MokoOpenGraph requires PHP %s or later." +PKG_MOKOOG_PHP_VERSION_ERROR="MokoJoomOpenGraph requires PHP %s or later." diff --git a/source/language/en-US/index.html b/source/language/en-US/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/language/en-US/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/language/en-US/pkg_mokoog.sys.ini b/source/language/en-US/pkg_mokoog.sys.ini similarity index 65% rename from src/language/en-US/pkg_mokoog.sys.ini rename to source/language/en-US/pkg_mokoog.sys.ini index 76e8e3d..47aa587 100644 --- a/src/language/en-US/pkg_mokoog.sys.ini +++ b/source/language/en-US/pkg_mokoog.sys.ini @@ -1,7 +1,7 @@ -; MokoOpenGraph - Package System Language File +; MokoJoomOpenGraph - Package System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PKG_MOKOOG="MokoOpenGraph" +PKG_MOKOOG="MokoJoomOpenGraph" PKG_MOKOOG_DESCRIPTION="Complete Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Control how every page appears when shared on Facebook, Twitter/X, LinkedIn, WhatsApp, and more." -PKG_MOKOOG_PHP_VERSION_ERROR="MokoOpenGraph requires PHP %s or later." +PKG_MOKOOG_PHP_VERSION_ERROR="MokoJoomOpenGraph requires PHP %s or later." diff --git a/source/language/index.html b/source/language/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/language/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/api/index.html b/source/packages/com_mokoog/api/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/api/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/api/src/Controller/TagsController.php b/source/packages/com_mokoog/api/src/Controller/TagsController.php new file mode 100644 index 0000000..9fbe168 --- /dev/null +++ b/source/packages/com_mokoog/api/src/Controller/TagsController.php @@ -0,0 +1,68 @@ +<?php + +/** + * @package MokoJoomOpenGraph + * @subpackage com_mokoog.api + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Api\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Controller\ApiController; + +class TagsController extends ApiController +{ + /** + * The content type for JSON:API output. + * + * @var string + */ + protected $contentType = 'tags'; + + /** + * The default view for the API. + * + * @var string + */ + protected $default_view = 'tags'; + + /** + * Lookup an OG tag by content_type and content_id. + * + * GET /api/index.php/v1/mokoog/lookup/:content_type/:content_id + * + * @return static + */ + public function lookup(): static + { + $contentType = $this->input->getString('content_type', ''); + $contentId = $this->input->getInt('content_id', 0); + + if (empty($contentType) || $contentId <= 0) { + throw new \RuntimeException('content_type and content_id are required', 400); + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType)) + ->where($db->quoteName('content_id') . ' = ' . $contentId); + + $db->setQuery($query); + $id = $db->loadResult(); + + if (!$id) { + throw new \RuntimeException('OG tag not found for ' . $contentType . ':' . $contentId, 404); + } + + $this->input->set('id', $id); + + return $this->displayItem(); + } +} diff --git a/source/packages/com_mokoog/api/src/Controller/index.html b/source/packages/com_mokoog/api/src/Controller/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/api/src/Controller/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php b/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php new file mode 100644 index 0000000..97a0aa2 --- /dev/null +++ b/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php @@ -0,0 +1,66 @@ +<?php + +/** + * @package MokoJoomOpenGraph + * @subpackage com_mokoog.api + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Api\View\Tags; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\JsonApiView as BaseApiView; + +class JsonapiView extends BaseApiView +{ + /** + * The fields to render in the API response. + * + * Whitelist of fields from #__mokoog_tags that are safe to expose. + * + * @var array + */ + protected $fieldsToRenderItem = [ + 'id', + 'content_type', + 'content_id', + 'og_title', + 'og_description', + 'og_image', + 'og_type', + 'seo_title', + 'meta_description', + 'robots', + 'canonical_url', + 'language', + 'published', + 'created', + 'modified', + ]; + + /** + * The fields to render in list responses. + * + * @var array + */ + protected $fieldsToRenderList = [ + 'id', + 'content_type', + 'content_id', + 'og_title', + 'og_description', + 'og_image', + 'og_type', + 'seo_title', + 'meta_description', + 'robots', + 'canonical_url', + 'language', + 'published', + 'created', + 'modified', + ]; +} diff --git a/source/packages/com_mokoog/api/src/View/Tags/index.html b/source/packages/com_mokoog/api/src/View/Tags/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/api/src/View/Tags/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/api/src/View/index.html b/source/packages/com_mokoog/api/src/View/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/api/src/View/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/api/src/index.html b/source/packages/com_mokoog/api/src/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/api/src/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/com_mokoog/forms/filter_tags.xml b/source/packages/com_mokoog/forms/filter_tags.xml similarity index 98% rename from src/packages/com_mokoog/forms/filter_tags.xml rename to source/packages/com_mokoog/forms/filter_tags.xml index d7e87b4..b4d4015 100644 --- a/src/packages/com_mokoog/forms/filter_tags.xml +++ b/source/packages/com_mokoog/forms/filter_tags.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/forms/index.html b/source/packages/com_mokoog/forms/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/forms/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/com_mokoog/forms/tag.xml b/source/packages/com_mokoog/forms/tag.xml similarity index 65% rename from src/packages/com_mokoog/forms/tag.xml rename to source/packages/com_mokoog/forms/tag.xml index 363ef5f..87d8068 100644 --- a/src/packages/com_mokoog/forms/tag.xml +++ b/source/packages/com_mokoog/forms/tag.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -70,4 +70,37 @@ <option value="0">JUNPUBLISHED</option> </field> </fieldset> + <fieldset name="seo" label="SEO Meta Tags"> + <field + name="seo_title" + type="text" + label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE" + description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC" + filter="string" + maxlength="70" + /> + <field + name="meta_description" + type="textarea" + label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION" + description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC" + filter="string" + rows="3" + maxlength="200" + /> + <field + name="robots" + type="text" + label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS" + description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC" + filter="string" + /> + <field + name="canonical_url" + type="url" + label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL" + description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC" + filter="url" + /> + </fieldset> </form> diff --git a/source/packages/com_mokoog/index.html b/source/packages/com_mokoog/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/language/en-GB/com_mokoog.ini b/source/packages/com_mokoog/language/en-GB/com_mokoog.ini new file mode 100644 index 0000000..f970f01 --- /dev/null +++ b/source/packages/com_mokoog/language/en-GB/com_mokoog.ini @@ -0,0 +1,59 @@ +; MokoJoomOpenGraph - Component Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +COM_MOKOOG="MokoJoomOpenGraph" +COM_MOKOOG_TAGS_TITLE="MokoJoomOpenGraph - Tag Manager" +COM_MOKOOG_SUBMENU_TAGS="Tags" +COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." +COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" +COM_MOKOOG_AUTO_GENERATED="auto-generated" + +COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type" +COM_MOKOOG_HEADING_CONTENT_ID="Content ID" +COM_MOKOOG_HEADING_OG_TITLE="OG Title" +COM_MOKOOG_HEADING_IMAGE="Image" +COM_MOKOOG_HEADING_SEO="SEO" +COM_MOKOOG_HEADING_DEBUG="Debug" +COM_MOKOOG_HEADING_MODIFIED="Modified" + +COM_MOKOOG_SEO_OK="OK" +COM_MOKOOG_SEO_MISSING_DESC="No meta description" +COM_MOKOOG_SEO_TITLE_LONG="SEO title too long" +COM_MOKOOG_SEO_NOINDEX="noindex" + +COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type" +COM_MOKOOG_FIELD_CONTENT_ID="Content ID" +COM_MOKOOG_FIELD_OG_TITLE="OG Title" +COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing." +COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description" +COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing." +COM_MOKOOG_FIELD_OG_IMAGE="OG Image" +COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing." +COM_MOKOOG_FIELD_OG_TYPE="OG Type" +COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type." + +COM_MOKOOG_FILTER_SEARCH="Search OG titles" +COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type" +COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -" +COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending" +COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending" +COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending" +COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending" + +COM_MOKOOG_TOOLBAR_BATCH_GENERATE="Batch Generate" +COM_MOKOOG_BATCH_TITLE="Batch OG Tag Generation" +COM_MOKOOG_BATCH_COUNTING="Counting articles without OG tags..." +COM_MOKOOG_BATCH_NONE="All articles already have OG tags." +COM_MOKOOG_BATCH_FOUND="articles found without OG tags." +COM_MOKOOG_BATCH_PROCESSED="processed" +COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!" +COM_MOKOOG_BATCH_ERROR="Error:" + +COM_MOKOOG_TOOLBAR_EXPORT="Export CSV" +COM_MOKOOG_TOOLBAR_IMPORT="Import CSV" +COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded." +COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file." +COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s." +COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file." +COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped." diff --git a/src/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini b/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini similarity index 72% rename from src/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini rename to source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini index be6c090..0aacbc4 100644 --- a/src/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini +++ b/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoOpenGraph - Component System Language File +; MokoJoomOpenGraph - Component System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -COM_MOKOOG="MokoOpenGraph" +COM_MOKOOG="MokoJoomOpenGraph" COM_MOKOOG_DESCRIPTION="Manage Open Graph and social sharing tags for all your content. View, edit, and batch-process OG metadata." diff --git a/source/packages/com_mokoog/language/en-GB/index.html b/source/packages/com_mokoog/language/en-GB/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/language/en-GB/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/language/en-US/com_mokoog.ini b/source/packages/com_mokoog/language/en-US/com_mokoog.ini new file mode 100644 index 0000000..f970f01 --- /dev/null +++ b/source/packages/com_mokoog/language/en-US/com_mokoog.ini @@ -0,0 +1,59 @@ +; MokoJoomOpenGraph - Component Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +COM_MOKOOG="MokoJoomOpenGraph" +COM_MOKOOG_TAGS_TITLE="MokoJoomOpenGraph - Tag Manager" +COM_MOKOOG_SUBMENU_TAGS="Tags" +COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." +COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" +COM_MOKOOG_AUTO_GENERATED="auto-generated" + +COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type" +COM_MOKOOG_HEADING_CONTENT_ID="Content ID" +COM_MOKOOG_HEADING_OG_TITLE="OG Title" +COM_MOKOOG_HEADING_IMAGE="Image" +COM_MOKOOG_HEADING_SEO="SEO" +COM_MOKOOG_HEADING_DEBUG="Debug" +COM_MOKOOG_HEADING_MODIFIED="Modified" + +COM_MOKOOG_SEO_OK="OK" +COM_MOKOOG_SEO_MISSING_DESC="No meta description" +COM_MOKOOG_SEO_TITLE_LONG="SEO title too long" +COM_MOKOOG_SEO_NOINDEX="noindex" + +COM_MOKOOG_FIELD_CONTENT_TYPE="Content Type" +COM_MOKOOG_FIELD_CONTENT_ID="Content ID" +COM_MOKOOG_FIELD_OG_TITLE="OG Title" +COM_MOKOOG_FIELD_OG_TITLE_DESC="Custom title for social sharing." +COM_MOKOOG_FIELD_OG_DESCRIPTION="OG Description" +COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC="Custom description for social sharing." +COM_MOKOOG_FIELD_OG_IMAGE="OG Image" +COM_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing." +COM_MOKOOG_FIELD_OG_TYPE="OG Type" +COM_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type." + +COM_MOKOOG_FILTER_SEARCH="Search OG titles" +COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type" +COM_MOKOOG_FILTER_SELECT_TYPE="- Select Type -" +COM_MOKOOG_HEADING_OG_TITLE_ASC="OG Title ascending" +COM_MOKOOG_HEADING_OG_TITLE_DESC="OG Title descending" +COM_MOKOOG_HEADING_MODIFIED_ASC="Modified ascending" +COM_MOKOOG_HEADING_MODIFIED_DESC="Modified descending" + +COM_MOKOOG_TOOLBAR_BATCH_GENERATE="Batch Generate" +COM_MOKOOG_BATCH_TITLE="Batch OG Tag Generation" +COM_MOKOOG_BATCH_COUNTING="Counting articles without OG tags..." +COM_MOKOOG_BATCH_NONE="All articles already have OG tags." +COM_MOKOOG_BATCH_FOUND="articles found without OG tags." +COM_MOKOOG_BATCH_PROCESSED="processed" +COM_MOKOOG_BATCH_COMPLETE="Batch generation complete!" +COM_MOKOOG_BATCH_ERROR="Error:" + +COM_MOKOOG_TOOLBAR_EXPORT="Export CSV" +COM_MOKOOG_TOOLBAR_IMPORT="Import CSV" +COM_MOKOOG_IMPORT_NO_FILE="No CSV file was uploaded." +COM_MOKOOG_IMPORT_INVALID_TYPE="Invalid file type. Please upload a .csv file." +COM_MOKOOG_IMPORT_FILE_TOO_LARGE="File is too large. Maximum allowed size is %s." +COM_MOKOOG_IMPORT_READ_ERROR="Could not read the uploaded CSV file." +COM_MOKOOG_IMPORT_RESULT="Import complete: %d created, %d updated, %d skipped." diff --git a/src/packages/com_mokoog/language/en-US/com_mokoog.sys.ini b/source/packages/com_mokoog/language/en-US/com_mokoog.sys.ini similarity index 72% rename from src/packages/com_mokoog/language/en-US/com_mokoog.sys.ini rename to source/packages/com_mokoog/language/en-US/com_mokoog.sys.ini index be6c090..0aacbc4 100644 --- a/src/packages/com_mokoog/language/en-US/com_mokoog.sys.ini +++ b/source/packages/com_mokoog/language/en-US/com_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoOpenGraph - Component System Language File +; MokoJoomOpenGraph - Component System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -COM_MOKOOG="MokoOpenGraph" +COM_MOKOOG="MokoJoomOpenGraph" COM_MOKOOG_DESCRIPTION="Manage Open Graph and social sharing tags for all your content. View, edit, and batch-process OG metadata." diff --git a/source/packages/com_mokoog/language/en-US/index.html b/source/packages/com_mokoog/language/en-US/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/language/en-US/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/language/index.html b/source/packages/com_mokoog/language/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/language/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/com_mokoog/mokoog.xml b/source/packages/com_mokoog/mokoog.xml similarity index 91% rename from src/packages/com_mokoog/mokoog.xml rename to source/packages/com_mokoog/mokoog.xml index 05711b2..c4d796a 100644 --- a/src/packages/com_mokoog/mokoog.xml +++ b/source/packages/com_mokoog/mokoog.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -8,7 +8,7 @@ --> <extension type="component" method="upgrade"> <name>com_mokoog</name> - <version>01.01.00</version> + <version>01.01.01</version> <creationDate>2026-05-23</creationDate> <author>Moko Consulting</author> <authorEmail>hello@mokoconsulting.tech</authorEmail> @@ -42,6 +42,7 @@ <filename>provider.php</filename> </files> <files folder="src"> + <folder>ContentType</folder> <folder>Controller</folder> <folder>Extension</folder> <folder>Model</folder> @@ -68,4 +69,10 @@ <menu link="option=com_mokoog&view=tags">COM_MOKOOG_SUBMENU_TAGS</menu> </submenu> </administration> + + <api> + <files folder="api"> + <folder>src</folder> + </files> + </api> </extension> diff --git a/src/packages/com_mokoog/script.php b/source/packages/com_mokoog/script.php similarity index 81% rename from src/packages/com_mokoog/script.php rename to source/packages/com_mokoog/script.php index 1dc0a47..5073df5 100644 --- a/src/packages/com_mokoog/script.php +++ b/source/packages/com_mokoog/script.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -23,7 +23,7 @@ class Com_MokoOGInstallerScript */ public function install(InstallerAdapter $parent): void { - echo '<p>MokoOpenGraph component installed successfully.</p>'; + echo '<p>MokoJoomOpenGraph component installed successfully.</p>'; } /** @@ -35,6 +35,6 @@ class Com_MokoOGInstallerScript */ public function update(InstallerAdapter $parent): void { - echo '<p>MokoOpenGraph component updated successfully.</p>'; + echo '<p>MokoJoomOpenGraph component updated successfully.</p>'; } } diff --git a/source/packages/com_mokoog/services/index.html b/source/packages/com_mokoog/services/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/services/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/com_mokoog/services/provider.php b/source/packages/com_mokoog/services/provider.php similarity index 97% rename from src/packages/com_mokoog/services/provider.php rename to source/packages/com_mokoog/services/provider.php index 249c8a1..27c3354 100644 --- a/src/packages/com_mokoog/services/provider.php +++ b/source/packages/com_mokoog/services/provider.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/sql/index.html b/source/packages/com_mokoog/sql/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/sql/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/com_mokoog/sql/install.mysql.sql b/source/packages/com_mokoog/sql/install.mysql.sql similarity index 69% rename from src/packages/com_mokoog/sql/install.mysql.sql rename to source/packages/com_mokoog/sql/install.mysql.sql index 884d369..cc3558a 100644 --- a/src/packages/com_mokoog/sql/install.mysql.sql +++ b/source/packages/com_mokoog/sql/install.mysql.sql @@ -1,5 +1,5 @@ -- --- MokoOpenGraph - Database Schema +-- MokoJoomOpenGraph - Database Schema -- Copyright (C) 2026 Moko Consulting. All rights reserved. -- License: GPL-3.0-or-later -- @@ -12,10 +12,15 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` ( `og_description` TEXT NOT NULL, `og_image` VARCHAR(512) NOT NULL DEFAULT '', `og_type` VARCHAR(50) NOT NULL DEFAULT 'article', + `seo_title` VARCHAR(70) NOT NULL DEFAULT '', + `meta_description` VARCHAR(200) NOT NULL DEFAULT '', + `robots` VARCHAR(100) NOT NULL DEFAULT '', + `canonical_url` VARCHAR(512) NOT NULL DEFAULT '', + `language` CHAR(7) NOT NULL DEFAULT '*', `published` TINYINT(1) NOT NULL DEFAULT 1, `created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', `modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (`id`), - UNIQUE KEY `idx_content` (`content_type`, `content_id`), + UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`), KEY `idx_published` (`published`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/packages/com_mokoog/sql/uninstall.mysql.sql b/source/packages/com_mokoog/sql/uninstall.mysql.sql similarity index 58% rename from src/packages/com_mokoog/sql/uninstall.mysql.sql rename to source/packages/com_mokoog/sql/uninstall.mysql.sql index ab50f15..8652ac6 100644 --- a/src/packages/com_mokoog/sql/uninstall.mysql.sql +++ b/source/packages/com_mokoog/sql/uninstall.mysql.sql @@ -1,5 +1,5 @@ -- --- MokoOpenGraph - Uninstall +-- MokoJoomOpenGraph - Uninstall -- DROP TABLE IF EXISTS `#__mokoog_tags`; diff --git a/source/packages/com_mokoog/sql/updates/index.html b/source/packages/com_mokoog/sql/updates/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/com_mokoog/sql/updates/mysql/01.00.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.00.00.sql similarity index 100% rename from src/packages/com_mokoog/sql/updates/mysql/01.00.00.sql rename to source/packages/com_mokoog/sql/updates/mysql/01.00.00.sql diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql new file mode 100644 index 0000000..5bac763 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql @@ -0,0 +1,9 @@ +-- +-- MokoJoomOpenGraph 01.01.00 — Add SEO meta management columns +-- + +ALTER TABLE `#__mokoog_tags` + ADD COLUMN `seo_title` VARCHAR(70) NOT NULL DEFAULT '' AFTER `og_type`, + ADD COLUMN `meta_description` VARCHAR(200) NOT NULL DEFAULT '' AFTER `seo_title`, + ADD COLUMN `robots` VARCHAR(100) NOT NULL DEFAULT '' AFTER `meta_description`, + ADD COLUMN `canonical_url` VARCHAR(512) NOT NULL DEFAULT '' AFTER `robots`; diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql new file mode 100644 index 0000000..ca87208 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql @@ -0,0 +1,10 @@ +-- +-- MokoJoomOpenGraph 01.02.00 — Add multilingual OG tag support +-- + +ALTER TABLE `#__mokoog_tags` + ADD COLUMN `language` CHAR(7) NOT NULL DEFAULT '*' AFTER `canonical_url`; + +ALTER TABLE `#__mokoog_tags` + DROP INDEX `idx_content`, + ADD UNIQUE KEY `idx_content_lang` (`content_type`, `content_id`, `language`); diff --git a/source/packages/com_mokoog/sql/updates/mysql/index.html b/source/packages/com_mokoog/sql/updates/mysql/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/src/ContentType/index.html b/source/packages/com_mokoog/src/ContentType/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/ContentType/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/src/Controller/BatchController.php b/source/packages/com_mokoog/src/Controller/BatchController.php new file mode 100644 index 0000000..68141f4 --- /dev/null +++ b/source/packages/com_mokoog/src/Controller/BatchController.php @@ -0,0 +1,182 @@ +<?php + +/** + * @package MokoSuiteOpenGraph + * @subpackage com_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Response\JsonResponse; +use Joomla\CMS\Session\Session; + +class BatchController extends BaseController +{ + /** + * Count the total articles eligible for batch generation. + * + * @return void + */ + public function count(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__content', 'c')) + ->leftJoin( + $db->quoteName('#__mokoog_tags', 't') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->where($db->quoteName('c.state') . ' = 1') + ->where($db->quoteName('t.id') . ' IS NULL'); + + $db->setQuery($query); + $total = (int) $db->loadResult(); + + echo new JsonResponse(['total' => $total]); + + Factory::getApplication()->close(); + } + + /** + * Process a chunk of articles for batch OG generation. + * + * @return void + */ + public function process(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + if (!Factory::getApplication()->getIdentity()->authorise('core.create', 'com_mokoog')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $app = Factory::getApplication(); + $limit = min($app->getInput()->getInt('limit', 50), 200); + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName([ + 'c.id', 'c.title', 'c.metadesc', 'c.introtext', 'c.fulltext', 'c.images', + ])) + ->from($db->quoteName('#__content', 'c')) + ->leftJoin( + $db->quoteName('#__mokoog_tags', 't') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->where($db->quoteName('c.state') . ' = 1') + ->where($db->quoteName('t.id') . ' IS NULL') + ->order($db->quoteName('c.id') . ' ASC'); + + // Always offset=0: processed articles now have #__mokoog_tags rows + // and are excluded by the LEFT JOIN ... IS NULL filter automatically. + $db->setQuery($query, 0, $limit); + $articles = $db->loadObjectList(); + + $created = 0; + $skipped = 0; + $now = Factory::getDate()->toSql(); + + foreach ($articles as $article) { + $ogTitle = $article->title; + $ogDescription = $this->extractDescription($article); + $ogImage = $this->extractImage($article); + + $record = (object) [ + 'content_type' => 'com_content', + 'content_id' => (int) $article->id, + 'og_title' => $ogTitle, + 'og_description' => $ogDescription, + 'og_image' => $ogImage, + 'og_type' => 'article', + 'seo_title' => '', + 'meta_description' => $article->metadesc ?: '', + 'robots' => '', + 'canonical_url' => '', + 'language' => '*', + 'published' => 1, + 'created' => $now, + 'modified' => $now, + ]; + + try { + $db->insertObject('#__mokoog_tags', $record); + $created++; + } catch (\RuntimeException $e) { + $skipped++; + } + } + + echo new JsonResponse([ + 'created' => $created, + ]); + + $app->close(); + } + + /** + * Extract a description from article content. + * + * @param object $article Article record + * + * @return string + */ + private function extractDescription(object $article): string + { + // Prefer meta description if set + if (!empty($article->metadesc)) { + return $article->metadesc; + } + + // Fall back to intro text + $text = $article->introtext ?: $article->fulltext; + $text = strip_tags($text); + $text = trim(preg_replace('/\s+/', ' ', $text)); + + if (mb_strlen($text) > 160) { + $text = mb_substr($text, 0, 157) . '...'; + } + + return $text; + } + + /** + * Extract the best image from article data. + * + * @param object $article Article record + * + * @return string + */ + private function extractImage(object $article): string + { + if (!empty($article->images)) { + $images = json_decode($article->images, true); + + if (!empty($images['image_fulltext'])) { + return $images['image_fulltext']; + } + + if (!empty($images['image_intro'])) { + return $images['image_intro']; + } + } + + return ''; + } +} diff --git a/src/packages/com_mokoog/src/Controller/DisplayController.php b/source/packages/com_mokoog/src/Controller/DisplayController.php similarity index 94% rename from src/packages/com_mokoog/src/Controller/DisplayController.php rename to source/packages/com_mokoog/src/Controller/DisplayController.php index d65ffaf..28b73c9 100644 --- a/src/packages/com_mokoog/src/Controller/DisplayController.php +++ b/source/packages/com_mokoog/src/Controller/DisplayController.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/Controller/ImportExportController.php b/source/packages/com_mokoog/src/Controller/ImportExportController.php new file mode 100644 index 0000000..5fcb49f --- /dev/null +++ b/source/packages/com_mokoog/src/Controller/ImportExportController.php @@ -0,0 +1,255 @@ +<?php + +/** + * @package MokoSuiteOpenGraph + * @subpackage com_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Session\Session; + +class ImportExportController extends BaseController +{ + /** + * Maximum upload file size in bytes (2 MB). + */ + private const MAX_FILE_SIZE = 2 * 1024 * 1024; + + /** + * Allowed content_type patterns for import. + */ + private const CONTENT_TYPE_PATTERN = '/^[a-z][a-z0-9_.]*$/'; + + /** + * Export all OG tags as CSV. + * + * @return void + */ + public function export(): void + { + Session::checkToken('get') || jexit(Text::_('JINVALID_TOKEN')); + + if (!Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_mokoog')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $app = Factory::getApplication(); + $db = Factory::getDbo(); + + // Join with #__content to get article titles for reference + $query = $db->getQuery(true) + ->select([ + $db->quoteName('t.content_type'), + $db->quoteName('t.content_id'), + 'COALESCE(' . $db->quoteName('c.title') . ', ' . $db->quote('') . ') AS ' . $db->quoteName('article_title'), + $db->quoteName('t.og_title'), + $db->quoteName('t.og_description'), + $db->quoteName('t.og_image'), + $db->quoteName('t.og_type'), + $db->quoteName('t.seo_title'), + $db->quoteName('t.meta_description'), + $db->quoteName('t.robots'), + $db->quoteName('t.canonical_url'), + $db->quoteName('t.language'), + ]) + ->from($db->quoteName('#__mokoog_tags', 't')) + ->leftJoin( + $db->quoteName('#__content', 'c') + . ' ON ' . $db->quoteName('t.content_type') . ' = ' . $db->quote('com_content') + . ' AND ' . $db->quoteName('t.content_id') . ' = ' . $db->quoteName('c.id') + ) + ->order($db->quoteName('t.content_type') . ', ' . $db->quoteName('t.content_id')); + + $db->setQuery($query); + $rows = $db->loadAssocList(); + + // Send CSV headers + $app->setHeader('Content-Type', 'text/csv; charset=utf-8'); + $app->setHeader('Content-Disposition', 'attachment; filename="mokoog_tags_export.csv"'); + $app->sendHeaders(); + + $output = fopen('php://output', 'w'); + + // Header row + fputcsv($output, [ + 'content_type', 'content_id', 'article_title', + 'og_title', 'og_description', 'og_image', 'og_type', + 'seo_title', 'meta_description', 'robots', 'canonical_url', + 'language', + ]); + + foreach ($rows as $row) { + fputcsv($output, $row); + } + + fclose($output); + $app->close(); + } + + /** + * Import OG tags from uploaded CSV. + * + * @return void + */ + public function import(): void + { + Session::checkToken() || jexit(Text::_('JINVALID_TOKEN')); + + $identity = Factory::getApplication()->getIdentity(); + + if (!$identity->authorise('core.create', 'com_mokoog') || !$identity->authorise('core.edit', 'com_mokoog')) { + throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 403); + } + + $app = Factory::getApplication(); + $input = $app->getInput(); + $files = $input->files->get('jform', [], 'array'); + + if (empty($files['csv_file']['tmp_name'])) { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_NO_FILE'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + $csvFile = $files['csv_file']; + + // Validate file extension + $ext = strtolower(pathinfo($csvFile['name'] ?? '', PATHINFO_EXTENSION)); + + if ($ext !== 'csv') { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + // Validate MIME type + $allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel']; + + if (!empty($csvFile['type']) && !\in_array($csvFile['type'], $allowedMimes, true)) { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_INVALID_TYPE'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + // Validate file size + if (($csvFile['size'] ?? 0) > self::MAX_FILE_SIZE) { + $app->enqueueMessage(Text::sprintf('COM_MOKOOG_IMPORT_FILE_TOO_LARGE', '2 MB'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + $tmpFile = $csvFile['tmp_name']; + $handle = fopen($tmpFile, 'r'); + + if (!$handle) { + $app->enqueueMessage(Text::_('COM_MOKOOG_IMPORT_READ_ERROR'), 'error'); + $app->redirect('index.php?option=com_mokoog&view=tags'); + + return; + } + + $db = Factory::getDbo(); + $header = fgetcsv($handle); + $created = 0; + $updated = 0; + $skipped = 0; + $now = Factory::getDate()->toSql(); + + while (($row = fgetcsv($handle)) !== false) { + if (\count($row) < 7) { + $skipped++; + + continue; + } + + $contentType = trim($row[0]); + $contentId = (int) $row[1]; + // $row[2] = article_title (informational, skip) + $ogTitle = trim($row[3] ?? ''); + $ogDescription = trim($row[4] ?? ''); + $ogImage = trim($row[5] ?? ''); + $ogType = trim($row[6] ?? 'article'); + $seoTitle = trim($row[7] ?? ''); + $metaDesc = trim($row[8] ?? ''); + $robots = trim($row[9] ?? ''); + $canonicalUrl = trim($row[10] ?? ''); + $language = trim($row[11] ?? '*'); + + // Validate language tag format (e.g., 'en-GB', '*') + if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) { + $language = '*'; + } + + if (empty($contentType) || $contentId <= 0) { + $skipped++; + + continue; + } + + // Validate content_type against allowed pattern + if (!preg_match(self::CONTENT_TYPE_PATTERN, $contentType)) { + $skipped++; + + continue; + } + + // Check for existing record (unique key includes language) + $query = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType)) + ->where($db->quoteName('content_id') . ' = ' . $contentId) + ->where($db->quoteName('language') . ' = ' . $db->quote($language)); + + $db->setQuery($query); + $existingId = $db->loadResult(); + + $record = (object) [ + 'content_type' => $contentType, + 'content_id' => $contentId, + 'og_title' => $ogTitle, + 'og_description' => $ogDescription, + 'og_image' => $ogImage, + 'og_type' => $ogType, + 'seo_title' => $seoTitle, + 'meta_description' => $metaDesc, + 'robots' => $robots, + 'canonical_url' => $canonicalUrl, + 'language' => $language, + 'published' => 1, + 'modified' => $now, + ]; + + if ($existingId) { + $record->id = $existingId; + $db->updateObject('#__mokoog_tags', $record, 'id'); + $updated++; + } else { + $record->created = $now; + $db->insertObject('#__mokoog_tags', $record); + $created++; + } + } + + fclose($handle); + + $app->enqueueMessage( + Text::sprintf('COM_MOKOOG_IMPORT_RESULT', $created, $updated, $skipped), + 'success' + ); + $app->redirect('index.php?option=com_mokoog&view=tags'); + } +} diff --git a/source/packages/com_mokoog/src/Controller/TagsController.php b/source/packages/com_mokoog/src/Controller/TagsController.php new file mode 100644 index 0000000..00d2000 --- /dev/null +++ b/source/packages/com_mokoog/src/Controller/TagsController.php @@ -0,0 +1,33 @@ +<?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); + } +} diff --git a/source/packages/com_mokoog/src/Controller/index.html b/source/packages/com_mokoog/src/Controller/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Controller/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/com_mokoog/src/Extension/MokoOGComponent.php b/source/packages/com_mokoog/src/Extension/MokoOGComponent.php similarity index 92% rename from src/packages/com_mokoog/src/Extension/MokoOGComponent.php rename to source/packages/com_mokoog/src/Extension/MokoOGComponent.php index 65307b3..f8e3a9e 100644 --- a/src/packages/com_mokoog/src/Extension/MokoOGComponent.php +++ b/source/packages/com_mokoog/src/Extension/MokoOGComponent.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/Extension/index.html b/source/packages/com_mokoog/src/Extension/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Extension/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/src/Field/index.html b/source/packages/com_mokoog/src/Field/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Field/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/src/Model/TagModel.php b/source/packages/com_mokoog/src/Model/TagModel.php new file mode 100644 index 0000000..c56b682 --- /dev/null +++ b/source/packages/com_mokoog/src/Model/TagModel.php @@ -0,0 +1,68 @@ +<?php + +/** + * @package MokoJoomOpenGraph + * @subpackage com_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoOG\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\AdminModel; + +class TagModel extends AdminModel +{ + /** + * Get the form for the item. + * + * @param array $data Form data + * @param bool $loadData Load data from state + * + * @return \Joomla\CMS\Form\Form|false + */ + public function getForm($data = [], $loadData = true) + { + $form = $this->loadForm( + 'com_mokoog.tag', + 'tag', + ['control' => 'jform', 'load_data' => $loadData] + ); + + return $form ?: false; + } + + /** + * Load the form data. + * + * @return object + */ + protected function loadFormData(): object + { + $data = Factory::getApplication()->getUserState('com_mokoog.edit.tag.data', []); + + if (empty($data)) { + $data = $this->getItem(); + } + + return $data; + } + + /** + * Get the table class name. + * + * @param string $name Table name + * @param string $prefix Table prefix + * @param array $options Table options + * + * @return \Joomla\CMS\Table\Table + */ + public function getTable($name = 'Tag', $prefix = 'Administrator', $options = []) + { + return parent::getTable($name, $prefix, $options); + } +} diff --git a/src/packages/com_mokoog/src/Model/TagsModel.php b/source/packages/com_mokoog/src/Model/TagsModel.php similarity index 98% rename from src/packages/com_mokoog/src/Model/TagsModel.php rename to source/packages/com_mokoog/src/Model/TagsModel.php index 6294af5..4171c21 100644 --- a/src/packages/com_mokoog/src/Model/TagsModel.php +++ b/source/packages/com_mokoog/src/Model/TagsModel.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/Model/index.html b/source/packages/com_mokoog/src/Model/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Model/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/src/Service/index.html b/source/packages/com_mokoog/src/Service/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Service/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/src/Table/TagTable.php b/source/packages/com_mokoog/src/Table/TagTable.php new file mode 100644 index 0000000..af85bac --- /dev/null +++ b/source/packages/com_mokoog/src/Table/TagTable.php @@ -0,0 +1,107 @@ +<?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; + } +} diff --git a/source/packages/com_mokoog/src/Table/index.html b/source/packages/com_mokoog/src/Table/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/Table/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/com_mokoog/src/View/Tags/HtmlView.php b/source/packages/com_mokoog/src/View/Tags/HtmlView.php similarity index 65% rename from src/packages/com_mokoog/src/View/Tags/HtmlView.php rename to source/packages/com_mokoog/src/View/Tags/HtmlView.php index 14ff7cb..eca4100 100644 --- a/src/packages/com_mokoog/src/View/Tags/HtmlView.php +++ b/source/packages/com_mokoog/src/View/Tags/HtmlView.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -39,6 +39,20 @@ class HtmlView extends BaseHtmlView */ protected $state; + /** + * The filter form. + * + * @var \Joomla\CMS\Form\Form|null + */ + public $filterForm; + + /** + * The active filters. + * + * @var array + */ + public $activeFilters = []; + /** * Display the view. * @@ -48,9 +62,11 @@ class HtmlView extends BaseHtmlView */ public function display($tpl = null): void { - $this->items = $this->get('Items'); - $this->pagination = $this->get('Pagination'); - $this->state = $this->get('State'); + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); $this->addToolbar(); @@ -65,6 +81,8 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { ToolbarHelper::title(Text::_('COM_MOKOOG_TAGS_TITLE'), 'bookmark'); + ToolbarHelper::custom('batch.generate', 'refresh', '', 'COM_MOKOOG_TOOLBAR_BATCH_GENERATE', false); + ToolbarHelper::custom('importexport.export', 'download', '', 'COM_MOKOOG_TOOLBAR_EXPORT', false); ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'tags.delete'); ToolbarHelper::preferences('com_mokoog'); } diff --git a/source/packages/com_mokoog/src/View/Tags/index.html b/source/packages/com_mokoog/src/View/Tags/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/View/Tags/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/src/View/Tags/tmpl/index.html b/source/packages/com_mokoog/src/View/Tags/tmpl/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/View/Tags/tmpl/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/src/View/index.html b/source/packages/com_mokoog/src/View/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/View/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/src/index.html b/source/packages/com_mokoog/src/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/src/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/tmpl/index.html b/source/packages/com_mokoog/tmpl/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/tmpl/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/com_mokoog/tmpl/tags/default.php b/source/packages/com_mokoog/tmpl/tags/default.php new file mode 100644 index 0000000..6ca4e90 --- /dev/null +++ b/source/packages/com_mokoog/tmpl/tags/default.php @@ -0,0 +1,243 @@ +<?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\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Uri\Uri; +use Joomla\CMS\Session\Session; + +/** @var \Joomla\Component\MokoOG\Administrator\View\Tags\HtmlView $this */ + +$token = Session::getFormToken(); +?> +<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm"> + <div class="row"> + <div class="col-md-12"> + <div id="j-main-container" class="j-main-container"> + <?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?> + + <?php if (empty($this->items)) : ?> + <div class="alert alert-info"> + <span class="icon-info-circle" aria-hidden="true"></span> + <?php echo Text::_('COM_MOKOOG_NO_TAGS'); ?> + </div> + <?php else : ?> + <table class="table" id="tagList"> + <caption class="visually-hidden"> + <?php echo Text::_('COM_MOKOOG_TABLE_CAPTION'); ?> + </caption> + <thead> + <tr> + <td class="w-1 text-center"> + <?php echo HTMLHelper::_('grid.checkall'); ?> + </td> + <th scope="col"> + <?php echo Text::_('COM_MOKOOG_HEADING_CONTENT_TYPE'); ?> + </th> + <th scope="col"> + <?php echo Text::_('COM_MOKOOG_HEADING_CONTENT_ID'); ?> + </th> + <th scope="col"> + <?php echo Text::_('COM_MOKOOG_HEADING_OG_TITLE'); ?> + </th> + <th scope="col" class="w-10"> + <?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?> + </th> + <th scope="col" class="w-10"> + <?php echo Text::_('COM_MOKOOG_HEADING_SEO'); ?> + </th> + <th scope="col" class="w-10"> + <?php echo Text::_('JSTATUS'); ?> + </th> + <th scope="col" class="w-10"> + <?php echo Text::_('COM_MOKOOG_HEADING_DEBUG'); ?> + </th> + <th scope="col" class="w-10"> + <?php echo Text::_('COM_MOKOOG_HEADING_MODIFIED'); ?> + </th> + <th scope="col" class="w-5"> + <?php echo Text::_('JGRID_HEADING_ID'); ?> + </th> + </tr> + </thead> + <tbody> + <?php foreach ($this->items as $i => $item) : ?> + <tr> + <td class="text-center"> + <?php echo HTMLHelper::_('grid.id', $i, $item->id); ?> + </td> + <td> + <?php echo $this->escape($item->content_type); ?> + </td> + <td> + <?php echo (int) $item->content_id; ?> + </td> + <td> + <?php echo $this->escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?> + </td> + <td> + <?php if ($item->og_image) : ?> + <span class="icon-image" aria-hidden="true" title="<?php echo $this->escape($item->og_image); ?>"></span> + <?php else : ?> + <span class="icon-minus-circle text-muted" aria-hidden="true"></span> + <?php endif; ?> + </td> + <td> + <?php + $seoIssues = []; + + if (empty($item->meta_description)) { + $seoIssues[] = Text::_('COM_MOKOOG_SEO_MISSING_DESC'); + } + + if (!empty($item->seo_title) && \strlen($item->seo_title) > 60) { + $seoIssues[] = Text::_('COM_MOKOOG_SEO_TITLE_LONG'); + } + + if (!empty($item->robots) && str_contains($item->robots, 'noindex')) { + $seoIssues[] = Text::_('COM_MOKOOG_SEO_NOINDEX'); + } + + if (empty($seoIssues)) : ?> + <span class="badge bg-success"><?php echo Text::_('COM_MOKOOG_SEO_OK'); ?></span> + <?php else : ?> + <?php foreach ($seoIssues as $issue) : ?> + <span class="badge bg-warning text-dark"><?php echo $issue; ?></span> + <?php endforeach; ?> + <?php endif; ?> + </td> + <td> + <?php echo $item->published ? Text::_('JPUBLISHED') : Text::_('JUNPUBLISHED'); ?> + </td> + <td class="mokoog-debug-links"> + <?php + // Build frontend URL for this content item + if ($item->content_type === 'com_content') { + $debugUrl = Uri::root() . 'index.php?option=com_content&view=article&id=' . (int) $item->content_id; + } elseif ($item->content_type === 'menu') { + $debugUrl = Uri::root() . 'index.php?Itemid=' . (int) $item->content_id; + } elseif ($item->content_type === 'com_content.category') { + $debugUrl = Uri::root() . 'index.php?option=com_content&view=category&id=' . (int) $item->content_id; + } else { + $debugUrl = Uri::root(); + } + ?> + <a href="https://developers.facebook.com/tools/debug/?q=<?php echo urlencode($debugUrl); ?>" target="_blank" rel="noopener" title="Facebook Debugger" class="btn btn-sm btn-outline-primary">FB</a> + <a href="https://www.linkedin.com/post-inspector/inspect/<?php echo urlencode($debugUrl); ?>" target="_blank" rel="noopener" title="LinkedIn Inspector" class="btn btn-sm btn-outline-info">LI</a> + <a href="https://search.google.com/test/rich-results?url=<?php echo urlencode($debugUrl); ?>" target="_blank" rel="noopener" title="Google Rich Results" class="btn btn-sm btn-outline-success">G</a> + </td> + <td> + <?php echo HTMLHelper::_('date', $item->modified, Text::_('DATE_FORMAT_LC4')); ?> + </td> + <td> + <?php echo (int) $item->id; ?> + </td> + </tr> + <?php endforeach; ?> + </tbody> + </table> + + <?php echo $this->pagination->getListFooter(); ?> + <?php endif; ?> + + <input type="hidden" name="task" value=""> + <input type="hidden" name="boxchecked" value="0"> + <?php echo HTMLHelper::_('form.token'); ?> + </div> + </div> + </div> +</form> + +<!-- Batch Generation Progress --> +<div id="mokoog-batch-panel" style="display:none;" class="card mt-3"> + <div class="card-body"> + <h4><?php echo Text::_('COM_MOKOOG_BATCH_TITLE'); ?></h4> + <div class="progress mb-2"> + <div id="mokoog-batch-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%">0%</div> + </div> + <p id="mokoog-batch-status"></p> + </div> +</div> + +<script> +document.addEventListener('DOMContentLoaded', function() { + // Intercept the batch.generate toolbar button + var origSubmitbutton = Joomla.submitbutton; + Joomla.submitbutton = function(task) { + if (task === 'batch.generate') { + mokoogBatchGenerate(); + return; + } + if (origSubmitbutton) { + origSubmitbutton(task); + } + }; + + function mokoogBatchGenerate() { + var panel = document.getElementById('mokoog-batch-panel'); + var bar = document.getElementById('mokoog-batch-bar'); + var status = document.getElementById('mokoog-batch-status'); + var token = '<?php echo $token; ?>'; + var chunkSize = 50; + + panel.style.display = 'block'; + status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COUNTING', true); ?>'; + + // Step 1: Count eligible articles + fetch('index.php?option=com_mokoog&task=batch.count&format=json&' + token + '=1') + .then(function(r) { return r.json(); }) + .then(function(resp) { + var total = resp.data.total; + if (total === 0) { + bar.style.width = '100%'; + bar.textContent = '100%'; + bar.classList.remove('progress-bar-animated'); + bar.classList.add('bg-success'); + status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_NONE', true); ?>'; + return; + } + status.textContent = total + ' <?php echo Text::_('COM_MOKOOG_BATCH_FOUND', true); ?>'; + processChunk(0, total, chunkSize, token, bar, status); + }) + .catch(function(err) { + status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message; + }); + } + + function processChunk(processed, total, chunkSize, token, bar, status) { + // Always offset=0: processed items are excluded by the IS NULL filter + fetch('index.php?option=com_mokoog&task=batch.process&format=json&limit=' + chunkSize + '&' + token + '=1') + .then(function(r) { return r.json(); }) + .then(function(resp) { + processed += resp.data.created; + var pct = Math.min(100, Math.round((processed / total) * 100)); + bar.style.width = pct + '%'; + bar.textContent = pct + '%'; + status.textContent = processed + ' / ' + total + ' <?php echo Text::_('COM_MOKOOG_BATCH_PROCESSED', true); ?>'; + + if (resp.data.created > 0 && processed < total) { + processChunk(processed, total, chunkSize, token, bar, status); + } else { + bar.classList.remove('progress-bar-animated'); + bar.classList.add('bg-success'); + status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_COMPLETE', true); ?> ' + processed + ' articles.'; + setTimeout(function() { location.reload(); }, 2000); + } + }) + .catch(function(err) { + status.textContent = '<?php echo Text::_('COM_MOKOOG_BATCH_ERROR', true); ?> ' + err.message; + }); + } +}); +</script> diff --git a/source/packages/com_mokoog/tmpl/tags/index.html b/source/packages/com_mokoog/tmpl/tags/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/com_mokoog/tmpl/tags/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/index.html b/source/packages/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/forms/index.html b/source/packages/plg_content_mokoog/forms/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/forms/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/plg_content_mokoog/forms/mokoog.xml b/source/packages/plg_content_mokoog/forms/mokoog.xml similarity index 53% rename from src/packages/plg_content_mokoog/forms/mokoog.xml rename to source/packages/plg_content_mokoog/forms/mokoog.xml index 1e9f026..90be310 100644 --- a/src/packages/plg_content_mokoog/forms/mokoog.xml +++ b/source/packages/plg_content_mokoog/forms/mokoog.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -50,5 +50,48 @@ <option value="video.other">Video</option> </field> </fieldset> + <fieldset name="mokoog_seo" label="PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL" + description="PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC"> + <field + name="seo_title" + type="text" + label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE" + description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC" + filter="string" + maxlength="70" + /> + <field + name="meta_description" + type="textarea" + label="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION" + description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC" + filter="string" + rows="3" + maxlength="200" + /> + <field + name="robots" + type="list" + label="PLG_CONTENT_MOKOOG_FIELD_ROBOTS" + description="PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC" + default="" + multiple="true" + > + <option value="">PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT</option> + <option value="noindex">noindex</option> + <option value="nofollow">nofollow</option> + <option value="nosnippet">nosnippet</option> + <option value="noarchive">noarchive</option> + <option value="noimageindex">noimageindex</option> + </field> + <field + name="canonical_url" + type="url" + label="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL" + description="PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC" + filter="url" + validate="url" + /> + </fieldset> </fields> </form> diff --git a/source/packages/plg_content_mokoog/index.html b/source/packages/plg_content_mokoog/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/language/en-GB/index.html b/source/packages/plg_content_mokoog/language/en-GB/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/language/en-GB/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini new file mode 100644 index 0000000..1da4c1e --- /dev/null +++ b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini @@ -0,0 +1,28 @@ +; 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_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." diff --git a/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini similarity index 66% rename from src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini rename to source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini index 418df7f..5d16a1b 100644 --- a/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini +++ b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoOpenGraph - Content Plugin System Language File +; MokoJoomOpenGraph - Content Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_CONTENT_MOKOOG="Content - MokoOpenGraph" +PLG_CONTENT_MOKOOG="Content - MokoJoomOpenGraph" PLG_CONTENT_MOKOOG_DESCRIPTION="Adds Open Graph fields to article and menu item edit forms for per-page social sharing control." diff --git a/source/packages/plg_content_mokoog/language/en-US/index.html b/source/packages/plg_content_mokoog/language/en-US/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/language/en-US/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini new file mode 100644 index 0000000..1da4c1e --- /dev/null +++ b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini @@ -0,0 +1,28 @@ +; 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_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." diff --git a/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini similarity index 66% rename from src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini rename to source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini index 418df7f..5d16a1b 100644 --- a/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini +++ b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoOpenGraph - Content Plugin System Language File +; MokoJoomOpenGraph - Content Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_CONTENT_MOKOOG="Content - MokoOpenGraph" +PLG_CONTENT_MOKOOG="Content - MokoJoomOpenGraph" PLG_CONTENT_MOKOOG_DESCRIPTION="Adds Open Graph fields to article and menu item edit forms for per-page social sharing control." diff --git a/source/packages/plg_content_mokoog/language/index.html b/source/packages/plg_content_mokoog/language/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/language/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/media/css/index.html b/source/packages/plg_content_mokoog/media/css/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/media/css/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/media/css/preview.css b/source/packages/plg_content_mokoog/media/css/preview.css new file mode 100644 index 0000000..0ad41bb --- /dev/null +++ b/source/packages/plg_content_mokoog/media/css/preview.css @@ -0,0 +1,104 @@ +/** + * @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; +} diff --git a/source/packages/plg_content_mokoog/media/index.html b/source/packages/plg_content_mokoog/media/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/media/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/media/joomla.asset.json b/source/packages/plg_content_mokoog/media/joomla.asset.json new file mode 100644 index 0000000..626a0fc --- /dev/null +++ b/source/packages/plg_content_mokoog/media/joomla.asset.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json", + "name": "plg_content_mokoog", + "version": "01.00.00", + "description": "MokoJoomOpenGraph Content Plugin Assets", + "license": "GPL-3.0-or-later", + "assets": [ + { + "name": "plg_content_mokoog.preview", + "type": "style", + "uri": "plg_content_mokoog/css/preview.css" + }, + { + "name": "plg_content_mokoog.preview", + "type": "script", + "uri": "plg_content_mokoog/js/preview.js", + "dependencies": ["core"] + } + ] +} diff --git a/source/packages/plg_content_mokoog/media/js/index.html b/source/packages/plg_content_mokoog/media/js/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/media/js/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/media/js/preview.js b/source/packages/plg_content_mokoog/media/js/preview.js new file mode 100644 index 0000000..a10c3f1 --- /dev/null +++ b/source/packages/plg_content_mokoog/media/js/preview.js @@ -0,0 +1,170 @@ +/** + * @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') + }; + + // Find the mokoog fieldset and insert preview after it + var fieldset = document.querySelector('[data-showon-id="mokoog"]') || + document.getElementById('attrib-mokoog') || + document.querySelector('fieldset.mokoog') || + document.querySelector('[id*="mokoog"]'); + + if (!fieldset) { + return; + } + + // Build preview DOM safely (no innerHTML with user data) + var preview = document.createElement('div'); + preview.id = 'mokoog-preview'; + + var wrapper = document.createElement('div'); + wrapper.className = 'mokoog-preview-wrapper'; + + var heading = document.createElement('h4'); + heading.className = 'mokoog-preview-heading'; + heading.textContent = 'Social Sharing Preview'; + wrapper.appendChild(heading); + + // Facebook preview card + var fbLabel = document.createElement('small'); + fbLabel.className = 'mokoog-platform-label'; + fbLabel.textContent = 'Facebook'; + wrapper.appendChild(fbLabel); + + var fbCard = document.createElement('div'); + fbCard.className = 'mokoog-card mokoog-card-fb'; + + var fbImg = document.createElement('div'); + fbImg.id = 'mokoog-fb-img'; + fbImg.className = 'mokoog-card-img'; + fbCard.appendChild(fbImg); + + var fbBody = document.createElement('div'); + fbBody.className = 'mokoog-card-body'; + + var fbDomain = document.createElement('div'); + fbDomain.id = 'mokoog-fb-domain'; + fbDomain.className = 'mokoog-card-domain'; + fbBody.appendChild(fbDomain); + + var fbTitle = document.createElement('div'); + fbTitle.id = 'mokoog-fb-title'; + fbTitle.className = 'mokoog-card-title'; + fbBody.appendChild(fbTitle); + + var fbDesc = document.createElement('div'); + fbDesc.id = 'mokoog-fb-desc'; + fbDesc.className = 'mokoog-card-desc'; + fbBody.appendChild(fbDesc); + + fbCard.appendChild(fbBody); + wrapper.appendChild(fbCard); + + // Twitter preview card + var twLabel = document.createElement('small'); + twLabel.className = 'mokoog-platform-label'; + twLabel.textContent = 'Twitter / X'; + wrapper.appendChild(twLabel); + + var twCard = document.createElement('div'); + twCard.className = 'mokoog-card mokoog-card-tw'; + + var twImg = document.createElement('div'); + twImg.id = 'mokoog-tw-img'; + twImg.className = 'mokoog-card-img'; + twCard.appendChild(twImg); + + var twBody = document.createElement('div'); + twBody.className = 'mokoog-card-body'; + + var twTitle = document.createElement('div'); + twTitle.id = 'mokoog-tw-title'; + twTitle.className = 'mokoog-card-title'; + twBody.appendChild(twTitle); + + var twDesc = document.createElement('div'); + twDesc.id = 'mokoog-tw-desc'; + twDesc.className = 'mokoog-card-desc'; + twBody.appendChild(twDesc); + + var twDomain = document.createElement('div'); + twDomain.id = 'mokoog-tw-domain'; + twDomain.className = 'mokoog-card-domain'; + twBody.appendChild(twDomain); + + twCard.appendChild(twBody); + wrapper.appendChild(twCard); + + preview.appendChild(wrapper); + fieldset.parentNode.insertBefore(preview, fieldset.nextSibling); + + var domain = window.location.hostname; + + function updatePreview() { + var title = (fields.ogTitle && fields.ogTitle.value) || + (fields.articleTitle && fields.articleTitle.value) || 'Page Title'; + var desc = (fields.ogDesc && fields.ogDesc.value) || + (fields.metaDesc && fields.metaDesc.value) || 'Page description will appear here...'; + var img = ''; + + if (fields.ogImage) { + img = fields.ogImage.value; + } + + if (title.length > 65) title = title.substring(0, 62) + '...'; + if (desc.length > 160) desc = desc.substring(0, 157) + '...'; + + // Facebook + document.getElementById('mokoog-fb-title').textContent = title; + document.getElementById('mokoog-fb-desc').textContent = desc; + document.getElementById('mokoog-fb-domain').textContent = domain; + var fbImgEl = document.getElementById('mokoog-fb-img'); + if (img) { + fbImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')'; + fbImgEl.style.display = ''; + } else { + fbImgEl.style.display = 'none'; + } + + // Twitter + document.getElementById('mokoog-tw-title').textContent = title; + document.getElementById('mokoog-tw-desc').textContent = desc; + document.getElementById('mokoog-tw-domain').textContent = domain; + var twImgEl = document.getElementById('mokoog-tw-img'); + if (img) { + twImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')'; + twImgEl.style.display = ''; + } else { + twImgEl.style.display = 'none'; + } + } + + Object.values(fields).forEach(function (el) { + if (el) { + el.addEventListener('input', updatePreview); + el.addEventListener('change', updatePreview); + } + }); + + if (fields.ogImage) { + var observer = new MutationObserver(updatePreview); + observer.observe(fields.ogImage, { attributes: true, attributeFilter: ['value'] }); + } + + updatePreview(); +}); diff --git a/src/packages/plg_content_mokoog/mokoog.php b/source/packages/plg_content_mokoog/mokoog.php similarity index 92% rename from src/packages/plg_content_mokoog/mokoog.php rename to source/packages/plg_content_mokoog/mokoog.php index 4800146..0f81e14 100644 --- a/src/packages/plg_content_mokoog/mokoog.php +++ b/source/packages/plg_content_mokoog/mokoog.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/src/packages/plg_content_mokoog/mokoog.xml b/source/packages/plg_content_mokoog/mokoog.xml similarity index 80% rename from src/packages/plg_content_mokoog/mokoog.xml rename to source/packages/plg_content_mokoog/mokoog.xml index 99741b5..00d3991 100644 --- a/src/packages/plg_content_mokoog/mokoog.xml +++ b/source/packages/plg_content_mokoog/mokoog.xml @@ -1,14 +1,14 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoOpenGraph + * @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 --> <extension type="plugin" group="content" method="upgrade"> - <name>Content - MokoOpenGraph</name> - <version>01.01.00</version> + <name>Content - MokoJoomOpenGraph</name> + <version>01.01.01</version> <creationDate>2026-05-23</creationDate> <author>Moko Consulting</author> <authorEmail>hello@mokoconsulting.tech</authorEmail> @@ -27,6 +27,12 @@ <folder>language</folder> </files> + <media destination="plg_content_mokoog" folder="media"> + <filename>joomla.asset.json</filename> + <folder>js</folder> + <folder>css</folder> + </media> + <languages> <language tag="en-GB">language/en-GB/plg_content_mokoog.ini</language> <language tag="en-GB">language/en-GB/plg_content_mokoog.sys.ini</language> diff --git a/source/packages/plg_content_mokoog/services/index.html b/source/packages/plg_content_mokoog/services/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/services/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/plg_content_mokoog/services/provider.php b/source/packages/plg_content_mokoog/services/provider.php similarity index 97% rename from src/packages/plg_content_mokoog/services/provider.php rename to source/packages/plg_content_mokoog/services/provider.php index d6f7e90..aca7a7d 100644 --- a/src/packages/plg_content_mokoog/services/provider.php +++ b/source/packages/plg_content_mokoog/services/provider.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php b/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php similarity index 57% rename from src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php rename to source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php index 07c1161..7bd1b9b 100644 --- a/src/packages/plg_content_mokoog/src/Extension/MokoOGContent.php +++ b/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -56,10 +56,11 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface $formName = $form->getName(); - // Add OG fields to article and menu item edit forms + // Add OG fields to article, menu item, and category edit forms $supportedForms = [ 'com_content.article', 'com_menus.item', + 'com_categories.categorycom_content', ]; if (!\in_array($formName, $supportedForms, true)) { @@ -71,6 +72,12 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface Form::addFormPath($formPath); $form->loadFile('mokoog', false); + // Load live preview assets + $wa = $this->getApplication()->getDocument()->getWebAssetManager(); + $wa->getRegistry()->addRegistryFile('media/plg_content_mokoog/joomla.asset.json'); + $wa->useStyle('plg_content_mokoog.preview'); + $wa->useScript('plg_content_mokoog.preview'); + // If editing an existing item, load saved OG data $id = 0; @@ -81,8 +88,14 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface } if ($id > 0) { - $contentType = ($formName === 'com_menus.item') ? 'menu' : 'com_content'; - $ogData = $this->loadOgData($contentType, $id); + $formTypeMap = [ + 'com_content.article' => 'com_content', + 'com_menus.item' => 'menu', + 'com_categories.categorycom_content' => 'com_content.category', + ]; + $contentType = $formTypeMap[$formName] ?? 'com_content'; + $language = $this->getContentLanguage($data); + $ogData = $this->loadOgData($contentType, $id, $language); if ($ogData) { $form->bind(['mokoog' => (array) $ogData]); @@ -102,8 +115,9 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface [$context, $article, $isNew] = array_values($event->getArguments()); $supportedContexts = [ - 'com_content.article' => 'com_content', - 'com_menus.item' => 'menu', + 'com_content.article' => 'com_content', + 'com_menus.item' => 'menu', + 'com_categories.categorycom_content' => 'com_content.category', ]; if (!isset($supportedContexts[$context])) { @@ -119,6 +133,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface $contentType = $supportedContexts[$context]; $contentId = (int) $article->id; + $language = $this->getContentLanguage($article); $input = $app->getInput(); $jform = $input->get('jform', [], 'array'); @@ -128,7 +143,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface return; } - $this->saveOgData($contentType, $contentId, $ogData); + $this->saveOgData($contentType, $contentId, $ogData, $language); } /** @@ -143,8 +158,9 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface [$context, $article] = array_values($event->getArguments()); $supportedContexts = [ - 'com_content.article' => 'com_content', - 'com_menus.item' => 'menu', + 'com_content.article' => 'com_content', + 'com_menus.item' => 'menu', + 'com_categories.categorycom_content' => 'com_content.category', ]; if (!isset($supportedContexts[$context])) { @@ -165,23 +181,30 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface } /** - * Load existing OG data for a content item. + * Load existing OG data for a content item, filtered by language. * * @param string $contentType Content type identifier * @param int $contentId Content ID + * @param string $language Language tag (e.g. 'en-GB') or '*' for all * * @return object|null */ - private function loadOgData(string $contentType, int $contentId): ?object + private function loadOgData(string $contentType, int $contentId, string $language = '*'): ?object { $db = Factory::getDbo(); $query = $db->getQuery(true) - ->select($db->quoteName(['og_title', 'og_description', 'og_image', 'og_type'])) + ->select($db->quoteName([ + 'og_title', 'og_description', 'og_image', 'og_type', + 'seo_title', 'meta_description', 'robots', 'canonical_url', + ])) ->from($db->quoteName('#__mokoog_tags')) ->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType)) - ->where($db->quoteName('content_id') . ' = ' . $contentId); + ->where($db->quoteName('content_id') . ' = ' . $contentId) + ->where('(' . $db->quoteName('language') . ' = ' . $db->quote($language) + . ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')') + ->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC'); - $db->setQuery($query); + $db->setQuery($query, 0, 1); return $db->loadObject(); } @@ -192,32 +215,46 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface * @param string $contentType Content type identifier * @param int $contentId Content ID * @param array $ogData OG field values + * @param string $language Language tag (e.g. 'en-GB') or '*' for all * * @return void */ - private function saveOgData(string $contentType, int $contentId, array $ogData): void + private function saveOgData(string $contentType, int $contentId, array $ogData, string $language = '*'): void { $db = Factory::getDbo(); - // Check if record exists + // Check if record exists for this content + language $query = $db->getQuery(true) ->select('id') ->from($db->quoteName('#__mokoog_tags')) ->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType)) - ->where($db->quoteName('content_id') . ' = ' . $contentId); + ->where($db->quoteName('content_id') . ' = ' . $contentId) + ->where($db->quoteName('language') . ' = ' . $db->quote($language)); $db->setQuery($query); $existingId = $db->loadResult(); + // Robots may come as array from multi-select, join with comma + $robots = $ogData['robots'] ?? ''; + + if (\is_array($robots)) { + $robots = implode(', ', array_filter($robots)); + } + $record = (object) [ - 'content_type' => $contentType, - 'content_id' => $contentId, - 'og_title' => trim($ogData['og_title'] ?? ''), - 'og_description' => trim($ogData['og_description'] ?? ''), - 'og_image' => trim($ogData['og_image'] ?? ''), - 'og_type' => trim($ogData['og_type'] ?? 'article'), - 'published' => 1, - 'modified' => Factory::getDate()->toSql(), + 'content_type' => $contentType, + 'content_id' => $contentId, + 'language' => $language, + 'og_title' => trim($ogData['og_title'] ?? ''), + 'og_description' => trim($ogData['og_description'] ?? ''), + 'og_image' => trim($ogData['og_image'] ?? ''), + 'og_type' => trim($ogData['og_type'] ?? 'article'), + 'seo_title' => trim($ogData['seo_title'] ?? ''), + 'meta_description' => trim($ogData['meta_description'] ?? ''), + 'robots' => trim($robots), + 'canonical_url' => trim($ogData['canonical_url'] ?? ''), + 'published' => 1, + 'modified' => Factory::getDate()->toSql(), ]; if ($existingId) { @@ -228,4 +265,24 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface $db->insertObject('#__mokoog_tags', $record); } } + + /** + * Extract the language tag from content data. + * + * @param object|array $data Content data from form or article object + * + * @return string Language tag (e.g. 'en-GB') or '*' for all languages + */ + private function getContentLanguage($data): string + { + $language = '*'; + + if (\is_object($data) && isset($data->language)) { + $language = $data->language; + } elseif (\is_array($data) && isset($data['language'])) { + $language = $data['language']; + } + + return !empty($language) ? $language : '*'; + } } diff --git a/source/packages/plg_content_mokoog/src/Extension/index.html b/source/packages/plg_content_mokoog/src/Extension/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/src/Extension/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/src/Field/index.html b/source/packages/plg_content_mokoog/src/Field/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/src/Field/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_content_mokoog/src/index.html b/source/packages/plg_content_mokoog/src/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_content_mokoog/src/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_system_mokoog/index.html b/source/packages/plg_system_mokoog/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_system_mokoog/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_system_mokoog/language/en-GB/index.html b/source/packages/plg_system_mokoog/language/en-GB/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_system_mokoog/language/en-GB/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini new file mode 100644 index 0000000..3d1c0f6 --- /dev/null +++ b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini @@ -0,0 +1,39 @@ +; 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_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_BREADCRUMBS="JSON-LD Breadcrumbs" +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway." diff --git a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini similarity index 68% rename from src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini rename to source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini index 51efd5f..2a356e2 100644 --- a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini +++ b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoOpenGraph - System Plugin System Language File +; MokoJoomOpenGraph - System Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_SYSTEM_MOKOOG="System - MokoOpenGraph" +PLG_SYSTEM_MOKOOG="System - MokoJoomOpenGraph" PLG_SYSTEM_MOKOOG_DESCRIPTION="Injects Open Graph and Twitter Card meta tags into every page for optimal social media sharing previews." diff --git a/source/packages/plg_system_mokoog/language/en-US/index.html b/source/packages/plg_system_mokoog/language/en-US/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_system_mokoog/language/en-US/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini new file mode 100644 index 0000000..3d1c0f6 --- /dev/null +++ b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini @@ -0,0 +1,39 @@ +; 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_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_BREADCRUMBS="JSON-LD Breadcrumbs" +PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway." diff --git a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini similarity index 68% rename from src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini rename to source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini index 51efd5f..2a356e2 100644 --- a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini +++ b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoOpenGraph - System Plugin System Language File +; MokoJoomOpenGraph - System Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_SYSTEM_MOKOOG="System - MokoOpenGraph" +PLG_SYSTEM_MOKOOG="System - MokoJoomOpenGraph" PLG_SYSTEM_MOKOOG_DESCRIPTION="Injects Open Graph and Twitter Card meta tags into every page for optimal social media sharing previews." diff --git a/source/packages/plg_system_mokoog/language/index.html b/source/packages/plg_system_mokoog/language/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_system_mokoog/language/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/plg_system_mokoog/mokoog.php b/source/packages/plg_system_mokoog/mokoog.php similarity index 93% rename from src/packages/plg_system_mokoog/mokoog.php rename to source/packages/plg_system_mokoog/mokoog.php index 47d238c..bfc5577 100644 --- a/src/packages/plg_system_mokoog/mokoog.php +++ b/source/packages/plg_system_mokoog/mokoog.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage plg_system_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/src/packages/plg_system_mokoog/mokoog.xml b/source/packages/plg_system_mokoog/mokoog.xml similarity index 61% rename from src/packages/plg_system_mokoog/mokoog.xml rename to source/packages/plg_system_mokoog/mokoog.xml index 38db7a5..864a622 100644 --- a/src/packages/plg_system_mokoog/mokoog.xml +++ b/source/packages/plg_system_mokoog/mokoog.xml @@ -1,14 +1,14 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoOpenGraph + * @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 - MokoOpenGraph</name> - <version>01.01.00</version> + <name>System - MokoJoomOpenGraph</name> + <version>01.01.01</version> <creationDate>2026-05-23</creationDate> <author>Moko Consulting</author> <authorEmail>hello@mokoconsulting.tech</authorEmail> @@ -41,6 +41,23 @@ 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" @@ -74,6 +91,21 @@ 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="" + /> </fieldset> <fieldset name="advanced" label="PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED"> <field @@ -107,6 +139,39 @@ min="50" max="300" /> + <field + name="auto_resize" + type="radio" + label="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE" + description="PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC" + default="1" + class="btn-group" + > + <option value="1">JYES</option> + <option value="0">JNO</option> + </field> + <field + name="jsonld_enabled" + type="radio" + label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED" + description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC" + default="1" + class="btn-group" + > + <option value="1">JYES</option> + <option value="0">JNO</option> + </field> + <field + name="jsonld_breadcrumbs" + type="radio" + label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS" + description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC" + default="1" + class="btn-group" + > + <option value="1">JYES</option> + <option value="0">JNO</option> + </field> </fieldset> </fields> </config> diff --git a/source/packages/plg_system_mokoog/services/index.html b/source/packages/plg_system_mokoog/services/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_system_mokoog/services/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/packages/plg_system_mokoog/services/provider.php b/source/packages/plg_system_mokoog/services/provider.php similarity index 97% rename from src/packages/plg_system_mokoog/services/provider.php rename to source/packages/plg_system_mokoog/services/provider.php index 2650be2..390b1d3 100644 --- a/src/packages/plg_system_mokoog/services/provider.php +++ b/source/packages/plg_system_mokoog/services/provider.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoOpenGraph + * @package MokoJoomOpenGraph * @subpackage plg_system_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php new file mode 100644 index 0000000..cd7d710 --- /dev/null +++ b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php @@ -0,0 +1,697 @@ +<?php + +/** + * @package MokoSuiteOpenGraph + * @subpackage plg_system_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\System\MokoOG\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Uri\Uri; +use Joomla\Event\Event; +use Joomla\Event\SubscriberInterface; +use Joomla\Plugin\System\MokoOG\Helper\ImageHelper; +use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder; + +final class MokoOG extends CMSPlugin implements SubscriberInterface +{ + /** + * @var bool + */ + protected $autoloadLanguage = true; + + /** + * Returns the events this plugin subscribes to. + * + * @return array<string, string> + */ + public static function getSubscribedEvents(): array + { + return [ + 'onAfterRoute' => 'onAfterRoute', + 'onBeforeCompileHead' => 'onBeforeCompileHead', + ]; + } + + /** + * Run admin-side license key check after routing. + * + * @param Event $event The event object + * + * @return void + */ + public function onAfterRoute(Event $event): void + { + $app = $this->getApplication(); + + if ($app->isClient('administrator')) { + $this->warnMissingLicenseKey(); + } + } + + /** + * Inject Open Graph and Twitter Card meta tags before the document head is compiled. + * + * @param Event $event The event object + * + * @return void + */ + public function onBeforeCompileHead(Event $event): void + { + $app = $this->getApplication(); + + // Only run on the site frontend + if (!$app->isClient('site')) { + return; + } + + $doc = $app->getDocument(); + + if ($doc->getType() !== 'html') { + return; + } + + $input = $app->getInput(); + $option = $input->getCmd('option', ''); + $view = $input->getCmd('view', ''); + $id = $input->getInt('id', 0); + + // Try to load custom OG data from the database + $ogData = $this->loadOgData($option, $view, $id); + + // For category views, also try category-level OG data as fallback + if ($option === 'com_content' && $view === 'category' && $id > 0) { + $catOg = $this->loadOgDataByType('com_content.category', $id); + + if ($catOg) { + // Merge: category fills any gaps in the content-level data + foreach (['og_title', 'og_description', 'og_image', 'og_type', 'seo_title', 'meta_description', 'robots', 'canonical_url'] as $field) { + if (empty($ogData->$field) && !empty($catOg->$field)) { + $ogData->$field = $catOg->$field; + } + } + } + } + + // --- SEO meta tags (set first, before OG) --- + $this->applySeoTags($doc, $ogData); + + // Build tag values — custom OG data → site-wide defaults → auto-generated + $defaultTitle = $this->params->get('default_og_title', ''); + $defaultDesc = $this->params->get('default_og_description', ''); + + $title = $ogData->og_title ?: ($doc->getTitle() ?: $defaultTitle); + $description = $ogData->og_description ?: ($this->buildDescription($doc) ?: $defaultDesc); + $image = $ogData->og_image ?: $this->findImage($option, $view, $id); + $url = Uri::getInstance()->toString(); + $siteName = $this->params->get('og_site_name', $app->get('sitename', '')); + $defaultType = ($option === 'com_mokoshop' && $view === 'product') ? 'product' : 'article'; + $type = $ogData->og_type ?: $defaultType; + + // Open Graph tags + $doc->setMetaData('og:title', $title, 'property'); + $doc->setMetaData('og:description', $description, 'property'); + $doc->setMetaData('og:url', $url, 'property'); + $doc->setMetaData('og:type', $type, 'property'); + $doc->setMetaData('og:site_name', $siteName, 'property'); + + if ($image) { + $imageUrl = $this->resolveImageUrl($image); + $doc->setMetaData('og:image', $imageUrl, 'property'); + + // Emit actual image dimensions when detectable + $imageDims = $this->getImageDimensions($image); + + if ($imageDims) { + $doc->setMetaData('og:image:width', (string) $imageDims[0], 'property'); + $doc->setMetaData('og:image:height', (string) $imageDims[1], 'property'); + } + } + + // og:locale from current language + $langTag = Factory::getLanguage()->getTag(); + $ogLocale = str_replace('-', '_', $langTag); + $doc->setMetaData('og:locale', $ogLocale, 'property'); + + // Facebook App ID + $fbAppId = $this->params->get('fb_app_id', ''); + + if ($fbAppId) { + $doc->setMetaData('fb:app_id', $fbAppId, 'property'); + } + + // Twitter Card tags + $cardType = $this->params->get('twitter_card_type', 'summary_large_image'); + $twitterSite = $this->params->get('twitter_site', ''); + + $doc->setMetaData('twitter:card', $cardType); + $doc->setMetaData('twitter:title', $title); + $doc->setMetaData('twitter:description', $description); + + if ($image) { + $doc->setMetaData('twitter:image', $this->resolveImageUrl($image)); + } + + if ($twitterSite) { + $doc->setMetaData('twitter:site', $twitterSite); + } + + // Telegram channel tag + $telegramChannel = $this->params->get('telegram_channel', ''); + + if ($telegramChannel) { + $doc->setMetaData('telegram:channel', $telegramChannel); + } + + // Discord embed color (theme-color meta tag) + $discordColor = $this->params->get('discord_color', ''); + + if ($discordColor) { + $doc->setMetaData('theme-color', $discordColor); + } + + // LinkedIn article tags + if ($option === 'com_content' && $view === 'article' && $id > 0) { + $doc->setMetaData('article:published_time', $this->getArticleDate($id, 'publish_up'), 'property'); + $doc->setMetaData('article:modified_time', $this->getArticleDate($id, 'modified'), 'property'); + + $author = $this->getArticleAuthor($id); + + if ($author) { + $doc->setMetaData('article:author', $author, 'property'); + } + } + + // MokoSuiteShop product meta tags + if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) { + $productData = $this->loadShopProduct($id); + + if ($productData) { + $doc->setMetaData('product:price:amount', number_format((float) $productData->price, 2, '.', ''), 'property'); + $doc->setMetaData('product:price:currency', $productData->currency ?: 'USD', 'property'); + } + } + + // Fire event so third-party plugins can add custom OG/social tags + $eventData = [ + 'subject' => $doc, + 'title' => $title, + 'description' => $description, + 'image' => $image ? $this->resolveImageUrl($image) : '', + 'url' => $url, + 'type' => $type, + 'option' => $option, + 'view' => $view, + 'id' => $id, + ]; + $app->getDispatcher()->dispatch('onMokoOGAfterRender', new Event('onMokoOGAfterRender', $eventData)); + + // JSON-LD structured data + if ($this->params->get('jsonld_enabled', 1)) { + $imageUrl = $image ? $this->resolveImageUrl($image) : ''; + + if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) { + $schema = JsonLdBuilder::buildProduct($id, $title, $description, $imageUrl, $this->loadShopProduct($id)); + } elseif ($option === 'com_content' && $view === 'article' && $id > 0) { + $schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl, $this->loadArticle($id)); + } else { + $schema = JsonLdBuilder::buildWebPage($title, $description); + } + + if ($schema) { + $doc->addCustomTag(JsonLdBuilder::toScriptTag($schema)); + } + + if ($this->params->get('jsonld_breadcrumbs', 1)) { + $breadcrumbs = JsonLdBuilder::buildBreadcrumbs(); + + if ($breadcrumbs) { + $doc->addCustomTag(JsonLdBuilder::toScriptTag($breadcrumbs)); + } + } + } + } + + /** + * Apply SEO meta tags (title, description, robots, canonical) to the document. + * + * @param \Joomla\CMS\Document\HtmlDocument $doc The document + * @param object $ogData The loaded OG/SEO data + * + * @return void + */ + private function applySeoTags($doc, object $ogData): void + { + // Custom SEO title overrides the page <title> + if (!empty($ogData->seo_title)) { + $doc->setTitle($ogData->seo_title); + } + + // Custom meta description + if (!empty($ogData->meta_description)) { + $doc->setDescription($ogData->meta_description); + } + + // Robots directive + if (!empty($ogData->robots)) { + $doc->setMetaData('robots', $ogData->robots); + } + + // Canonical URL + if (!empty($ogData->canonical_url)) { + // Remove any existing canonical link via public API + $headData = $doc->getHeadData(); + + if (!empty($headData['links'])) { + foreach ($headData['links'] as $link => $attribs) { + if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') { + unset($headData['links'][$link]); + } + } + + $doc->setHeadData($headData); + } + + $doc->addHeadLink($ogData->canonical_url, 'canonical'); + } + } + + /** + * Load custom OG data from the database for the current page. + * + * @param string $option Component option + * @param string $view View name + * @param int $id Content ID + * + * @return object + */ + private function loadOgData(string $option, string $view, int $id): object + { + $empty = (object) [ + 'og_title' => '', + 'og_description' => '', + 'og_image' => '', + 'og_type' => '', + 'seo_title' => '', + 'meta_description' => '', + 'robots' => '', + 'canonical_url' => '', + ]; + + if (!$id) { + // Try menu-item-based lookup + $menuItem = $this->getApplication()->getMenu()->getActive(); + + if (!$menuItem) { + return $empty; + } + + return $this->loadOgDataByMenu((int) $menuItem->id) ?: $empty; + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote($option)) + ->where($db->quoteName('content_id') . ' = ' . (int) $id) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('language') . ' = ' . $db->quote(Factory::getLanguage()->getTag()) + . ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')') + ->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC'); + + $db->setQuery($query, 0, 1); + + return $db->loadObject() ?: $empty; + } + + /** + * Load OG data by content type and ID. + * + * @param string $contentType Content type identifier + * @param int $contentId Content ID + * + * @return object|null + */ + private function loadOgDataByType(string $contentType, int $contentId): ?object + { + $db = Factory::getDbo(); + $lang = Factory::getLanguage()->getTag(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType)) + ->where($db->quoteName('content_id') . ' = ' . $contentId) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang) + . ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')') + ->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC'); + + $db->setQuery($query, 0, 1); + + return $db->loadObject(); + } + + /** + * Load OG data by menu item ID. + * + * @param int $menuId Menu item ID + * + * @return object|null + */ + private function loadOgDataByMenu(int $menuId): ?object + { + $db = Factory::getDbo(); + $lang = Factory::getLanguage()->getTag(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokoog_tags')) + ->where($db->quoteName('content_type') . ' = ' . $db->quote('menu')) + ->where($db->quoteName('content_id') . ' = ' . $menuId) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang) + . ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')') + ->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC'); + + $db->setQuery($query, 0, 1); + + return $db->loadObject(); + } + + /** + * Build a description from the document metadata or page content. + * + * @param \Joomla\CMS\Document\HtmlDocument $doc The document + * + * @return string + */ + private function buildDescription($doc): string + { + $description = $doc->getDescription(); + $maxLength = (int) $this->params->get('desc_length', 160); + + if ($this->params->get('strip_html', 1)) { + $description = strip_tags($description); + } + + $description = trim(preg_replace('/\s+/', ' ', $description)); + + if (mb_strlen($description) > $maxLength) { + $description = mb_substr($description, 0, $maxLength - 3) . '...'; + } + + return $description; + } + + /** + * Attempt to find the first image for the given content. + * + * @param string $option Component option + * @param string $view View name + * @param int $id Content ID + * + * @return string + */ + private function findImage(string $option, string $view, int $id): string + { + if (!$this->params->get('auto_generate', 1)) { + return $this->params->get('default_image', ''); + } + + // For MokoSuiteShop products, look at the linked article's images + if ($option === 'com_mokoshop' && $id > 0) { + $productData = $this->loadShopProduct($id); + + if ($productData && !empty($productData->images)) { + $imagesData = json_decode($productData->images, true); + + if (!empty($imagesData['image_fulltext'])) { + return $imagesData['image_fulltext']; + } + + if (!empty($imagesData['image_intro'])) { + return $imagesData['image_intro']; + } + } + + return $this->params->get('default_image', ''); + } + + // For Joomla articles, look at the intro/full image fields + if ($option === 'com_content' && $id > 0) { + $article = $this->loadArticle($id); + + if ($article && !empty($article->images)) { + $imagesData = json_decode($article->images, true); + + if (!empty($imagesData['image_fulltext'])) { + return $imagesData['image_fulltext']; + } + + if (!empty($imagesData['image_intro'])) { + return $imagesData['image_intro']; + } + } + + // Fallback: check the article's category for an image + if ($view === 'article') { + $db = Factory::getDbo(); + $catQuery = $db->getQuery(true) + ->select($db->quoteName('cat.params')) + ->from($db->quoteName('#__categories', 'cat')) + ->join('INNER', $db->quoteName('#__content', 'a') . ' ON ' . $db->quoteName('a.catid') . ' = ' . $db->quoteName('cat.id')) + ->where($db->quoteName('a.id') . ' = ' . (int) $id); + + $db->setQuery($catQuery); + $catParams = $db->loadResult(); + + if ($catParams) { + $catData = json_decode($catParams, true); + + if (!empty($catData['image'])) { + return $catData['image']; + } + } + } + } + + return $this->params->get('default_image', ''); + } + + /** + * Resolve a relative image path to a full URL, resizing for OG if needed. + * + * @param string $image Image path + * + * @return string + */ + private function resolveImageUrl(string $image): string + { + if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) { + return $image; + } + + // Auto-resize to OG recommended dimensions if enabled + if ($this->params->get('auto_resize', 1)) { + $image = ImageHelper::resize($image); + } + + return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/'); + } + + /** + * Load and cache a full article record with author for the current request. + * + * @param int $id Article ID + * + * @return object|null + */ + private function loadArticle(int $id): ?object + { + static $cache = []; + + if (isset($cache[$id])) { + return $cache[$id]; + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName([ + 'a.title', 'a.introtext', 'a.fulltext', 'a.images', + 'a.created', 'a.modified', 'a.publish_up', 'a.metadesc', + ])) + ->select($db->quoteName('u.name', 'author_name')) + ->from($db->quoteName('#__content', 'a')) + ->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by')) + ->where($db->quoteName('a.id') . ' = ' . $id); + + $db->setQuery($query); + $cache[$id] = $db->loadObject(); + + return $cache[$id]; + } + + /** + * Get a date field from an article. + * + * @param int $id Article ID + * @param string $field Date field name (publish_up, modified, created) + * + * @return string ISO 8601 date string + */ + private function getArticleDate(int $id, string $field): string + { + $article = $this->loadArticle($id); + + return $article->$field ?? ''; + } + + /** + * Get the author name for an article. + * + * @param int $id Article ID + * + * @return string + */ + private function getArticleAuthor(int $id): string + { + $article = $this->loadArticle($id); + + return $article->author_name ?? ''; + } + + /** + * Warn administrators once per session when no license key is configured. + * + * @return void + */ + private function warnMissingLicenseKey(): void + { + $session = Factory::getSession(); + + if ($session->get('mokoog.license_warned', false)) { + return; + } + + $user = Factory::getUser(); + + if ($user->guest || !$user->authorise('core.manage')) { + return; + } + + try { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName('extra_query')) + ->from($db->quoteName('#__update_sites')) + ->where($db->quoteName('name') . ' = ' . $db->quote('MokoSuiteOpenGraph Updates')) + ->setLimit(1); + $db->setQuery($query); + $extraQuery = (string) $db->loadResult(); + + // Mark as checked only after the DB query succeeds + $session->set('mokoog.license_warned', true); + + if (!empty($extraQuery)) { + parse_str($extraQuery, $parsed); + + if (!empty($parsed['dlid']) && preg_match('/^MOKO-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $parsed['dlid'])) { + return; + } + } + + $this->getApplication()->enqueueMessage( + '<strong>Moko Consulting License Key Required</strong> — ' + . 'No download key is configured. Updates will not be available until a valid license key is entered. ' + . 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> ' + . 'and enter your license key (<code>MOKO-XXXX-XXXX-XXXX-XXXX</code>) in the Download Key field ' + . 'for the MokoSuiteOpenGraph update site.', + 'warning' + ); + } catch (\Throwable $e) { + // Don't break admin over a license check, but log for debugging + \Joomla\CMS\Log\Log::add('MokoOG license check: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog'); + } + } + + /** + * Load MokoSuiteShop product data by product ID. + * + * @param int $productId CRM product ID + * + * @return object|null Product with name, description, images, price, currency, sku + */ + private function loadShopProduct(int $productId): ?object + { + static $cache = []; + + if (isset($cache[$productId])) { + return $cache[$productId]; + } + + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('p.id, p.sku, p.price, p.currency, p.stock_qty') + ->select('c.title AS name, c.introtext AS description, c.images') + ->from($db->quoteName('#__mokosuite_crm_products', 'p')) + ->join('LEFT', $db->quoteName('#__content', 'c') . ' ON c.id = p.article_id') + ->where($db->quoteName('p.id') . ' = ' . $productId) + ->where($db->quoteName('p.published') . ' = 1'); + + $db->setQuery($query); + $cache[$productId] = $db->loadObject(); + } catch (\RuntimeException $e) { + // MokoSuiteShop tables may not exist + $cache[$productId] = null; + } + + return $cache[$productId]; + } + + /** + * Get the actual pixel dimensions of a local image. + * + * @param string $image Image path (relative or absolute URL) + * + * @return array{0: int, 1: int}|null + */ + private function getImageDimensions(string $image): ?array + { + // Cannot determine dimensions for external URLs + if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) { + return null; + } + + // If auto-resize is on, the resized image lives in the generated dir + if ($this->params->get('auto_resize', 1)) { + $resolved = ImageHelper::resize($image); + } else { + $resolved = $image; + } + + $absPath = JPATH_ROOT . '/' . ltrim($resolved, '/'); + + if (!is_file($absPath)) { + return null; + } + + $info = getimagesize($absPath); + + if (!$info) { + return null; + } + + return [$info[0], $info[1]]; + } +} diff --git a/source/packages/plg_system_mokoog/src/Extension/index.html b/source/packages/plg_system_mokoog/src/Extension/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_system_mokoog/src/Extension/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php b/source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php new file mode 100644 index 0000000..7a16305 --- /dev/null +++ b/source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php @@ -0,0 +1,182 @@ +<?php + +/** + * @package MokoSuiteOpenGraph + * @subpackage plg_system_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\System\MokoOG\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Filesystem\Folder; +use Joomla\CMS\Log\Log; + +class ImageGenerator +{ + private const WIDTH = 1200; + private const HEIGHT = 630; + private const OUTPUT_DIR = 'images/mokoog/generated'; + + /** + * Generate an OG image with title text overlaid on a template background. + * + * @param string $title Article title to overlay + * @param string $templateImage Path to template/background image relative to JPATH_ROOT + * @param string $fontFile Absolute path to TTF font file + * @param int $fontSize Font size in points (default 42) + * @param array $fontColor RGB array [r, g, b] (default white) + * @param int $quality JPEG quality (default 90) + * + * @return string Path to generated image relative to JPATH_ROOT, or empty on failure + */ + public static function generate( + string $title, + string $templateImage, + string $fontFile = '', + int $fontSize = 42, + array $fontColor = [255, 255, 255], + int $quality = 90 + ): string { + if (!\extension_loaded('gd')) { + Log::add('MokoOG ImageGenerator: GD extension is not loaded. Image generation disabled.', Log::WARNING, 'mokoog'); + + return ''; + } + + $templateAbs = JPATH_ROOT . '/' . ltrim($templateImage, '/'); + + if (!is_file($templateAbs)) { + Log::add('MokoOG ImageGenerator: Template image not found: ' . $templateImage, Log::WARNING, 'mokoog'); + + return ''; + } + + if (!$fontFile || !is_file($fontFile)) { + Log::add('MokoOG ImageGenerator: TTF font file not found: ' . ($fontFile ?: '(not configured)'), Log::WARNING, 'mokoog'); + + return ''; + } + + $outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR; + + if (!is_dir($outputDir) && !Folder::create($outputDir)) { + Log::add('MokoOG ImageGenerator: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog'); + + return ''; + } + + $hash = md5($title . $templateImage . $fontSize); + $outputName = 'overlay_' . $hash . '.jpg'; + $outputPath = $outputDir . '/' . $outputName; + $outputRel = self::OUTPUT_DIR . '/' . $outputName; + + // Skip if already generated + if (is_file($outputPath)) { + return $outputRel; + } + + // Load template image + $imageInfo = getimagesize($templateAbs); + + if (!$imageInfo) { + Log::add('MokoOG ImageGenerator: Cannot read image dimensions: ' . $templateImage, Log::WARNING, 'mokoog'); + + return ''; + } + + $source = match ($imageInfo[2]) { + IMAGETYPE_JPEG => imagecreatefromjpeg($templateAbs), + IMAGETYPE_PNG => imagecreatefrompng($templateAbs), + IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($templateAbs) : false, + default => false, + }; + + if (!$source) { + Log::add('MokoOG ImageGenerator: Failed to load image (unsupported type or corrupt): ' . $templateImage, Log::WARNING, 'mokoog'); + + return ''; + } + + // Create output canvas at target dimensions + $canvas = imagecreatetruecolor(self::WIDTH, self::HEIGHT); + + imagecopyresampled( + $canvas, + $source, + 0, 0, 0, 0, + self::WIDTH, self::HEIGHT, + $imageInfo[0], $imageInfo[1] + ); + + imagedestroy($source); + + // Semi-transparent overlay for text readability + $overlay = imagecolorallocatealpha($canvas, 0, 0, 0, 64); + imagefilledrectangle($canvas, 0, (int) (self::HEIGHT * 0.55), self::WIDTH, self::HEIGHT, $overlay); + + // Render title text with word wrapping + $textColor = imagecolorallocate($canvas, $fontColor[0], $fontColor[1], $fontColor[2]); + $wrappedTitle = self::wrapText($title, $fontFile, $fontSize, (int) (self::WIDTH * 0.85)); + $textX = (int) (self::WIDTH * 0.075); + $textY = (int) (self::HEIGHT * 0.72); + + imagettftext($canvas, $fontSize, 0, $textX, $textY, $textColor, $fontFile, $wrappedTitle); + + // Save + imagejpeg($canvas, $outputPath, $quality); + imagedestroy($canvas); + + return $outputRel; + } + + /** + * Wrap text to fit within a maximum pixel width. + * + * @param string $text Text to wrap + * @param string $fontFile Path to TTF font + * @param int $fontSize Font size in points + * @param int $maxWidth Maximum width in pixels + * + * @return string Wrapped text with newlines + */ + private static function wrapText(string $text, string $fontFile, int $fontSize, int $maxWidth): string + { + $words = explode(' ', $text); + $lines = []; + $line = ''; + + foreach ($words as $word) { + $testLine = $line ? $line . ' ' . $word : $word; + $bbox = imagettfbbox($fontSize, 0, $fontFile, $testLine); + $lineWidth = abs($bbox[4] - $bbox[0]); + + if ($lineWidth > $maxWidth && $line !== '') { + $lines[] = $line; + $line = $word; + } else { + $line = $testLine; + } + } + + if ($line !== '') { + $lines[] = $line; + } + + // Limit to 3 lines, truncate last line if needed + if (\count($lines) > 3) { + $lines = \array_slice($lines, 0, 3); + + if (mb_strlen($lines[2]) > 3) { + $lines[2] = mb_substr($lines[2], 0, -3) . '...'; + } else { + $lines[2] .= '...'; + } + } + + return implode("\n", $lines); + } +} diff --git a/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php new file mode 100644 index 0000000..6bdca69 --- /dev/null +++ b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php @@ -0,0 +1,233 @@ +<?php + +/** + * @package MokoSuiteOpenGraph + * @subpackage plg_system_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\System\MokoOG\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Filesystem\File; +use Joomla\CMS\Filesystem\Folder; +use Joomla\CMS\Log\Log; + +class ImageHelper +{ + /** + * Target width for OG images (Facebook recommended). + */ + private const TARGET_WIDTH = 1200; + + /** + * Target height for OG images (Facebook recommended). + */ + private const TARGET_HEIGHT = 630; + + /** + * JPEG quality for generated images. + */ + private const JPEG_QUALITY = 85; + + /** + * Output directory relative to JPATH_ROOT. + */ + private const OUTPUT_DIR = 'images/mokoog/generated'; + + /** + * Resize an image to OG-optimized dimensions if needed. + * + * Returns the path to the resized image relative to JPATH_ROOT, + * or the original path if no resize was needed or possible. + * + * @param string $imagePath Image path relative to JPATH_ROOT + * @param int $targetWidth Target width (default 1200) + * @param int $targetHeight Target height (default 630) + * @param int $quality JPEG quality 1-100 (default 85) + * + * @return string Path to the output image (relative to JPATH_ROOT) + */ + public static function resize( + string $imagePath, + int $targetWidth = self::TARGET_WIDTH, + int $targetHeight = self::TARGET_HEIGHT, + int $quality = self::JPEG_QUALITY + ): string { + // Resolve absolute path + $absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/'); + + if (!is_file($absPath)) { + return $imagePath; + } + + $imageInfo = getimagesize($absPath); + + if (!$imageInfo) { + Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog'); + + return $imagePath; + } + + [$origWidth, $origHeight, $type] = $imageInfo; + + // Skip if already at or below target size + if ($origWidth <= $targetWidth && $origHeight <= $targetHeight) { + return $imagePath; + } + + // Ensure output directory exists + $outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR; + + if (!is_dir($outputDir) && !Folder::create($outputDir)) { + Log::add('MokoOG ImageHelper: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog'); + + return $imagePath; + } + + // Generate output filename based on source hash + dimensions + $hash = md5($imagePath . $targetWidth . $targetHeight); + $outputName = $hash . '.jpg'; + $outputPath = $outputDir . '/' . $outputName; + $outputRel = self::OUTPUT_DIR . '/' . $outputName; + + // Skip if already generated + if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) { + return $outputRel; + } + + // Load source image + $source = self::loadImage($absPath, $type); + + if (!$source) { + return $imagePath; + } + + // Calculate crop dimensions (center crop to target aspect ratio) + $targetRatio = $targetWidth / $targetHeight; + $sourceRatio = $origWidth / $origHeight; + + if ($sourceRatio > $targetRatio) { + // Source is wider — crop sides + $cropHeight = $origHeight; + $cropWidth = (int) round($origHeight * $targetRatio); + $cropX = (int) round(($origWidth - $cropWidth) / 2); + $cropY = 0; + } else { + // Source is taller — crop top/bottom + $cropWidth = $origWidth; + $cropHeight = (int) round($origWidth / $targetRatio); + $cropX = 0; + $cropY = (int) round(($origHeight - $cropHeight) / 2); + } + + // Create output canvas and resample + $output = imagecreatetruecolor($targetWidth, $targetHeight); + + imagecopyresampled( + $output, + $source, + 0, + 0, + $cropX, + $cropY, + $targetWidth, + $targetHeight, + $cropWidth, + $cropHeight + ); + + // Save as JPEG + imagejpeg($output, $outputPath, $quality); + + imagedestroy($source); + imagedestroy($output); + + return $outputRel; + } + + /** + * Remove a generated image file. + * + * @param string $generatedPath Path relative to JPATH_ROOT + * + * @return void + */ + public static function cleanup(string $generatedPath): void + { + if (empty($generatedPath) || !str_starts_with($generatedPath, self::OUTPUT_DIR)) { + return; + } + + $absPath = JPATH_ROOT . '/' . $generatedPath; + + if (is_file($absPath)) { + File::delete($absPath); + } + } + + /** + * Check if an image meets minimum OG size requirements. + * + * @param string $imagePath Image path relative to JPATH_ROOT + * + * @return array{valid: bool, width: int, height: int, message: string} + */ + public static function validate(string $imagePath): array + { + $absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/'); + + if (!is_file($absPath)) { + return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'File not found']; + } + + $imageInfo = getimagesize($absPath); + + if (!$imageInfo) { + return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'Not a valid image']; + } + + [$width, $height] = $imageInfo; + + // Facebook minimum: 200x200, recommended: 1200x630 + // WhatsApp minimum: 300x200 + if ($width < 200 || $height < 200) { + return [ + 'valid' => false, + 'width' => $width, + 'height' => $height, + 'message' => "Image too small ({$width}x{$height}). Minimum: 200x200px.", + ]; + } + + return ['valid' => true, 'width' => $width, 'height' => $height, 'message' => 'OK']; + } + + /** + * Load an image resource from a file. + * + * @param string $path Absolute file path + * @param int $type IMAGETYPE_* constant + * + * @return \GdImage|false + */ + private static function loadImage(string $path, int $type) + { + $image = match ($type) { + IMAGETYPE_JPEG => imagecreatefromjpeg($path), + IMAGETYPE_PNG => imagecreatefrompng($path), + IMAGETYPE_GIF => imagecreatefromgif($path), + IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($path) : false, + default => false, + }; + + if (!$image) { + Log::add('MokoOG ImageHelper: Failed to load image: ' . basename($path), Log::WARNING, 'mokoog'); + } + + return $image; + } +} diff --git a/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php b/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php new file mode 100644 index 0000000..7f0598a --- /dev/null +++ b/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php @@ -0,0 +1,267 @@ +<?php + +/** + * @package MokoSuiteOpenGraph + * @subpackage plg_system_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\System\MokoOG\Helper; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Uri\Uri; + +class JsonLdBuilder +{ + /** + * Build Article schema for a com_content article. + * + * @param int $articleId Article ID + * @param string $title Page title + * @param string $description Page description + * @param string $image Image URL (absolute) + * @param object|null $cachedArticle Pre-loaded article data (avoids duplicate query) + * + * @return array|null + */ + public static function buildArticle(int $articleId, string $title, string $description, string $image, ?object $cachedArticle = null): ?array + { + if ($articleId <= 0) { + return null; + } + + $article = $cachedArticle; + + if (!$article) { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName([ + 'a.created', 'a.modified', 'a.publish_up', + ])) + ->select($db->quoteName('u.name', 'author_name')) + ->from($db->quoteName('#__content', 'a')) + ->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by')) + ->where($db->quoteName('a.id') . ' = ' . $articleId); + + $db->setQuery($query); + $article = $db->loadObject(); + } + + if (!$article) { + return null; + } + + $schema = [ + '@context' => 'https://schema.org', + '@type' => 'Article', + 'headline' => $title, + 'description' => $description, + 'url' => Uri::getInstance()->toString(), + 'datePublished' => $article->publish_up ?: $article->created, + 'dateModified' => $article->modified ?: $article->created, + ]; + + $authorName = $article->author_name ?? ''; + + if (!empty($authorName)) { + $schema['author'] = [ + '@type' => 'Person', + 'name' => $authorName, + ]; + } + + if (!empty($image)) { + $schema['image'] = $image; + } + + return $schema; + } + + /** + * Build WebPage schema for non-article pages. + * + * @param string $title Page title + * @param string $description Page description + * + * @return array + */ + public static function buildWebPage(string $title, string $description): array + { + return [ + '@context' => 'https://schema.org', + '@type' => 'WebPage', + 'name' => $title, + 'description' => $description, + 'url' => Uri::getInstance()->toString(), + ]; + } + + /** + * Build BreadcrumbList schema from Joomla's pathway. + * + * @return array|null + */ + public static function buildBreadcrumbs(): ?array + { + $app = Factory::getApplication(); + $pathway = $app->getPathway(); + $items = $pathway->getPathway(); + + if (empty($items)) { + return null; + } + + $listItems = []; + $position = 1; + + foreach ($items as $item) { + $url = $item->link; + + if ($url && !str_starts_with($url, 'http')) { + $url = rtrim(Uri::root(), '/') . '/' . ltrim($url, '/'); + } + + $listItems[] = [ + '@type' => 'ListItem', + 'position' => $position, + 'name' => $item->name, + 'item' => $url ?: Uri::getInstance()->toString(), + ]; + + $position++; + } + + return [ + '@context' => 'https://schema.org', + '@type' => 'BreadcrumbList', + 'itemListElement' => $listItems, + ]; + } + + /** + * Build Organization schema from site configuration. + * + * @param string $siteName Site name + * + * @return array + */ + public static function buildOrganization(string $siteName): array + { + return [ + '@context' => 'https://schema.org', + '@type' => 'Organization', + 'name' => $siteName, + 'url' => Uri::root(), + ]; + } + + /** + * Build Product schema for a MokoSuiteShop product. + * + * @param int $productId CRM product ID + * @param string $title Product title + * @param string $description Product description + * @param string $image Image URL (absolute) + * @param object|null $cachedProduct Pre-loaded product data (avoids duplicate query) + * + * @return array|null + */ + public static function buildProduct(int $productId, string $title, string $description, string $image, ?object $cachedProduct = null): ?array + { + if ($productId <= 0) { + return null; + } + + $product = $cachedProduct; + + if (!$product) { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('p.sku, p.price, p.currency, p.stock_qty') + ->from($db->quoteName('#__mokosuite_crm_products', 'p')) + ->where($db->quoteName('p.id') . ' = ' . $productId) + ->where($db->quoteName('p.published') . ' = 1'); + + $db->setQuery($query); + $product = $db->loadObject(); + } + + if (!$product) { + return null; + } + + $schema = [ + '@context' => 'https://schema.org', + '@type' => 'Product', + 'name' => $title, + 'description' => $description, + 'url' => Uri::getInstance()->toString(), + ]; + + if (!empty($product->sku)) { + $schema['sku'] = $product->sku; + } + + if (!empty($image)) { + $schema['image'] = $image; + } + + // Offers (pricing and availability) + $availability = ((float) $product->stock_qty > 0) + ? 'https://schema.org/InStock' + : 'https://schema.org/OutOfStock'; + + $schema['offers'] = [ + '@type' => 'Offer', + 'price' => number_format((float) $product->price, 2, '.', ''), + 'priceCurrency' => $product->currency ?: 'USD', + 'availability' => $availability, + 'url' => Uri::getInstance()->toString(), + ]; + + // Aggregate rating from reviews if available + try { + $reviewQuery = $db->getQuery(true) + ->select('COUNT(*) AS review_count, AVG(rating) AS avg_rating') + ->from($db->quoteName('#__mokoshop_reviews')) + ->where($db->quoteName('product_id') . ' = ' . $productId) + ->where($db->quoteName('status') . ' = ' . $db->quote('approved')); + + $db->setQuery($reviewQuery); + $rating = $db->loadObject(); + + if ($rating && (int) $rating->review_count > 0) { + $schema['aggregateRating'] = [ + '@type' => 'AggregateRating', + 'ratingValue' => round((float) $rating->avg_rating, 1), + 'reviewCount' => (int) $rating->review_count, + ]; + } + } catch (\RuntimeException $e) { + // Reviews table may not exist if MokoSuiteShop reviews module not installed + } + + return $schema; + } + + /** + * Encode a schema array to a JSON-LD script tag string. + * + * @param array $schema Schema data + * + * @return string + */ + public static function toScriptTag(array $schema): string + { + $json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + + // Escape </ sequences to prevent XSS via </script> in content data + $json = str_replace('</', '<\/', $json); + + return '<script type="application/ld+json">' . $json . '</script>'; + } +} diff --git a/source/packages/plg_system_mokoog/src/Helper/index.html b/source/packages/plg_system_mokoog/src/Helper/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_system_mokoog/src/Helper/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_system_mokoog/src/index.html b/source/packages/plg_system_mokoog/src/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_system_mokoog/src/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_webservices_mokoog/index.html b/source/packages/plg_webservices_mokoog/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_webservices_mokoog/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_webservices_mokoog/language/en-GB/index.html b/source/packages/plg_webservices_mokoog/language/en-GB/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_webservices_mokoog/language/en-GB/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini b/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini new file mode 100644 index 0000000..97f99e3 --- /dev/null +++ b/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini @@ -0,0 +1,5 @@ +; MokoJoomOpenGraph - Web Services Plugin Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_WEBSERVICES_MOKOOG="Web Services - MokoJoomOpenGraph" diff --git a/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini b/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini new file mode 100644 index 0000000..086ffe8 --- /dev/null +++ b/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini @@ -0,0 +1,6 @@ +; MokoJoomOpenGraph - Web Services Plugin System Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_WEBSERVICES_MOKOOG="Web Services - MokoJoomOpenGraph" +PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoJoomOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags." diff --git a/source/packages/plg_webservices_mokoog/language/en-US/index.html b/source/packages/plg_webservices_mokoog/language/en-US/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_webservices_mokoog/language/en-US/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini b/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini new file mode 100644 index 0000000..97f99e3 --- /dev/null +++ b/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini @@ -0,0 +1,5 @@ +; MokoJoomOpenGraph - Web Services Plugin Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_WEBSERVICES_MOKOOG="Web Services - MokoJoomOpenGraph" diff --git a/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini b/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini new file mode 100644 index 0000000..086ffe8 --- /dev/null +++ b/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini @@ -0,0 +1,6 @@ +; MokoJoomOpenGraph - Web Services Plugin System Language File +; Copyright (C) 2026 Moko Consulting. All rights reserved. +; License: GPL-3.0-or-later + +PLG_WEBSERVICES_MOKOOG="Web Services - MokoJoomOpenGraph" +PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoJoomOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags." diff --git a/source/packages/plg_webservices_mokoog/language/index.html b/source/packages/plg_webservices_mokoog/language/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_webservices_mokoog/language/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_webservices_mokoog/mokoog.php b/source/packages/plg_webservices_mokoog/mokoog.php new file mode 100644 index 0000000..945c592 --- /dev/null +++ b/source/packages/plg_webservices_mokoog/mokoog.php @@ -0,0 +1,19 @@ +<?php + +/** + * @package MokoJoomOpenGraph + * @subpackage plg_webservices_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Legacy entry point — not executed under DI container. + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; + +class PlgWebservicesMokoog extends CMSPlugin +{ +} diff --git a/source/packages/plg_webservices_mokoog/mokoog.xml b/source/packages/plg_webservices_mokoog/mokoog.xml new file mode 100644 index 0000000..e9e7bc6 --- /dev/null +++ b/source/packages/plg_webservices_mokoog/mokoog.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + * @package MokoJoomOpenGraph + * @subpackage plg_webservices_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + --> +<extension type="plugin" group="webservices" method="upgrade"> + <name>Web Services - MokoJoomOpenGraph</name> + <version>01.01.01</version> + <creationDate>2026-05-23</creationDate> + <author>Moko Consulting</author> + <authorEmail>hello@mokoconsulting.tech</authorEmail> + <authorUrl>https://mokoconsulting.tech</authorUrl> + <copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright> + <license>GPL-3.0-or-later</license> + <description>PLG_WEBSERVICES_MOKOOG_DESCRIPTION</description> + + <namespace path="src">Joomla\Plugin\WebServices\MokoOG</namespace> + + <files> + <filename plugin="mokoog">mokoog.php</filename> + <folder>src</folder> + <folder>services</folder> + <folder>language</folder> + </files> + + <languages> + <language tag="en-GB">language/en-GB/plg_webservices_mokoog.ini</language> + <language tag="en-GB">language/en-GB/plg_webservices_mokoog.sys.ini</language> + </languages> +</extension> diff --git a/source/packages/plg_webservices_mokoog/services/index.html b/source/packages/plg_webservices_mokoog/services/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_webservices_mokoog/services/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_webservices_mokoog/services/provider.php b/source/packages/plg_webservices_mokoog/services/provider.php new file mode 100644 index 0000000..be36a42 --- /dev/null +++ b/source/packages/plg_webservices_mokoog/services/provider.php @@ -0,0 +1,44 @@ +<?php + +/** + * @package MokoJoomOpenGraph + * @subpackage plg_webservices_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Joomla\Plugin\WebServices\MokoOG\Extension\MokoOGWebServices; + +return new class () implements ServiceProviderInterface { + /** + * Register the service provider. + * + * @param Container $container The DI container + * + * @return void + */ + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoOGWebServices( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('webservices', 'mokoog') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php b/source/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php new file mode 100644 index 0000000..b5cc48f --- /dev/null +++ b/source/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php @@ -0,0 +1,81 @@ +<?php + +/** + * @package MokoJoomOpenGraph + * @subpackage plg_webservices_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\WebServices\MokoOG\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Router\ApiRouter; +use Joomla\Event\Event; +use Joomla\Event\SubscriberInterface; +use Joomla\Router\Route; + +final class MokoOGWebServices extends CMSPlugin implements SubscriberInterface +{ + /** + * @var bool + */ + protected $autoloadLanguage = true; + + /** + * Returns the events this plugin subscribes to. + * + * @return array<string, string> + */ + public static function getSubscribedEvents(): array + { + return [ + 'onBeforeApiRoute' => 'onBeforeApiRoute', + ]; + } + + /** + * Register API routes for MokoJoomOpenGraph. + * + * Endpoints: + * GET /api/index.php/v1/mokoog/tags - List all OG tags + * GET /api/index.php/v1/mokoog/tags/:id - Get single OG tag + * POST /api/index.php/v1/mokoog/tags - Create OG tag + * PATCH /api/index.php/v1/mokoog/tags/:id - Update OG tag + * DELETE /api/index.php/v1/mokoog/tags/:id - Delete OG tag + * + * @param Event $event The event object + * + * @return void + */ + public function onBeforeApiRoute(Event $event): void + { + [$router] = array_values($event->getArguments()); + + $defaults = [ + 'component' => 'com_mokoog', + 'public' => false, + ]; + + // CRUD routes for OG tags + $router->createCRUDRoutes( + 'v1/mokoog/tags', + 'tags', + $defaults + ); + + // GET by content type + content ID (lookup endpoint) + $router->addRoute( + new Route( + ['GET'], + 'v1/mokoog/lookup/:content_type/:content_id', + 'tags.lookup', + ['content_type' => '[a-z_.]+', 'content_id' => '(\d+)'], + $defaults + ) + ); + } +} diff --git a/source/packages/plg_webservices_mokoog/src/Extension/index.html b/source/packages/plg_webservices_mokoog/src/Extension/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_webservices_mokoog/src/Extension/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/source/packages/plg_webservices_mokoog/src/index.html b/source/packages/plg_webservices_mokoog/src/index.html new file mode 100644 index 0000000..94906bc --- /dev/null +++ b/source/packages/plg_webservices_mokoog/src/index.html @@ -0,0 +1 @@ +<html><body bgcolor="#FFFFFF"></body></html> diff --git a/src/pkg_mokoog.xml b/source/pkg_mokoog.xml similarity index 81% rename from src/pkg_mokoog.xml rename to source/pkg_mokoog.xml index 5b43180..6db9309 100644 --- a/src/pkg_mokoog.xml +++ b/source/pkg_mokoog.xml @@ -1,14 +1,14 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoOpenGraph + * @package MokoSuiteOpenGraph * @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="package" method="upgrade"> - <name>MokoOpenGraph</name> + <name>Package - MokoSuiteOpenGraph</name> <packagename>mokoog</packagename> - <version>01.01.00</version> + <version>01.01.01</version> <creationDate>2026-05-23</creationDate> <author>Moko Consulting</author> <authorEmail>hello@mokoconsulting.tech</authorEmail> @@ -23,15 +23,13 @@ <file type="component" id="com_mokoog">com_mokoog.zip</file> <file type="plugin" id="mokoog" group="system">plg_system_mokoog.zip</file> <file type="plugin" id="mokoog" group="content">plg_content_mokoog.zip</file> + <file type="plugin" id="mokoog" group="webservices">plg_webservices_mokoog.zip</file> </files> <languages> <language tag="en-GB">language/en-GB/pkg_mokoog.sys.ini</language> </languages> - <updateservers> - <server type="extension" name="MokoJoomOpenGraph Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/updates.xml</server> - </updateservers> <dlid prefix="dlid=" suffix=""/> <blockChildUninstall>true</blockChildUninstall> </extension> diff --git a/source/script.php b/source/script.php new file mode 100644 index 0000000..315a788 --- /dev/null +++ b/source/script.php @@ -0,0 +1,158 @@ +<?php + +/** + * @package MokoJoomOpenGraph + * @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\Installer\InstallerAdapter; +use Joomla\CMS\Language\Text; + +class Pkg_MokoOGInstallerScript +{ + protected $minimumJoomla = '4.0.0'; + protected $minimumPhp = '8.1.0'; + + + + + public function preflight(string $type, InstallerAdapter $parent): bool + { + if (version_compare(PHP_VERSION, $this->minimumPhp, '<')) + { + Factory::getApplication()->enqueueMessage( + Text::sprintf('PKG_MOKOOG_PHP_VERSION_ERROR', $this->minimumPhp), + 'error' + ); + + return false; + } + + $this->saveDownloadKey(); + + return true; + } + + public function postflight(string $type, InstallerAdapter $parent): void + { + $this->restoreDownloadKey(); + $this->warnMissingLicenseKey(); + + if ($type === 'install') + { + $db = Factory::getDbo(); + + foreach (['system', 'content', 'webservices'] as $folder) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 1') + ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote($folder)) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokoog')) + )->execute(); + } + } + } + + + + private ?string $savedDownloadKey = null; + + private function saveDownloadKey(): void + { + try + { + $db = \Joomla\CMS\Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('us.extra_query')) + ->from($db->quoteName('#__update_sites', 'us')) + ->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON use.update_site_id = us.update_site_id') + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id') + ->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokoog')) + ->setLimit(1) + ); + $key = $db->loadResult(); + if (!empty($key)) { $this->savedDownloadKey = $key; } + } + catch (\Throwable $e) { + \Joomla\CMS\Log\Log::add('MokoOG saveDownloadKey: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog'); + } + } + + private function restoreDownloadKey(): void + { + if ($this->savedDownloadKey === null) { return; } + + try + { + $db = \Joomla\CMS\Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('us.update_site_id')) + ->from($db->quoteName('#__update_sites', 'us')) + ->join('INNER', $db->quoteName('#__update_sites_extensions', 'use') . ' ON use.update_site_id = us.update_site_id') + ->join('INNER', $db->quoteName('#__extensions', 'e') . ' ON e.extension_id = use.extension_id') + ->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokoog')) + ->setLimit(1) + ); + $siteId = (int) $db->loadResult(); + if ($siteId > 0) + { + $db->setQuery( + $db->getQuery(true) + ->update($db->quoteName('#__update_sites')) + ->set($db->quoteName('extra_query') . ' = ' . $db->quote($this->savedDownloadKey)) + ->where($db->quoteName('update_site_id') . ' = ' . $siteId) + )->execute(); + } + } + catch (\Throwable $e) { + \Joomla\CMS\Log\Log::add('MokoOG restoreDownloadKey: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog'); + } + } + + private function warnMissingLicenseKey(): void + { + try + { + $db = \Joomla\CMS\Factory::getDbo(); + $db->setQuery( + $db->getQuery(true) + ->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')]) + ->from($db->quoteName('#__update_sites')) + ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoJoomOpenGraph%') . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoJoomOpenGraph%') . ')') + ->setLimit(1) + ); + $site = $db->loadObject(); + + if ($site) + { + $eq = (string) ($site->extra_query ?? ''); + if (!empty($eq) && strpos($eq, 'dlid=') !== false) { parse_str($eq, $p); if (!empty($p['dlid'])) { return; } } + $editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id; + } + else + { + $editUrl = 'index.php?option=com_installer&view=updatesites'; + } + + \Joomla\CMS\Factory::getApplication()->enqueueMessage( + '<strong>Moko Consulting License Key Required</strong> — ' + . 'No download key is configured. Updates will not be available until a valid license key is entered. ' + . '<a href="' . $editUrl . '" class="btn btn-sm btn-warning ms-2">Enter License Key</a>', + 'warning' + ); + } + catch (\Throwable $e) { + \Joomla\CMS\Log\Log::add('MokoOG warnMissingLicenseKey: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog'); + } + } +} diff --git a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini b/src/packages/com_mokoog/language/en-GB/com_mokoog.ini deleted file mode 100644 index 0e56059..0000000 --- a/src/packages/com_mokoog/language/en-GB/com_mokoog.ini +++ /dev/null @@ -1,16 +0,0 @@ -; MokoOpenGraph - Component Language File -; Copyright (C) 2026 Moko Consulting. All rights reserved. -; License: GPL-3.0-or-later - -COM_MOKOOG="MokoOpenGraph" -COM_MOKOOG_TAGS_TITLE="MokoOpenGraph - Tag Manager" -COM_MOKOOG_SUBMENU_TAGS="Tags" -COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." -COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" -COM_MOKOOG_AUTO_GENERATED="auto-generated" - -COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type" -COM_MOKOOG_HEADING_CONTENT_ID="Content ID" -COM_MOKOOG_HEADING_OG_TITLE="OG Title" -COM_MOKOOG_HEADING_IMAGE="Image" -COM_MOKOOG_HEADING_MODIFIED="Modified" diff --git a/src/packages/com_mokoog/language/en-US/com_mokoog.ini b/src/packages/com_mokoog/language/en-US/com_mokoog.ini deleted file mode 100644 index 0e56059..0000000 --- a/src/packages/com_mokoog/language/en-US/com_mokoog.ini +++ /dev/null @@ -1,16 +0,0 @@ -; MokoOpenGraph - Component Language File -; Copyright (C) 2026 Moko Consulting. All rights reserved. -; License: GPL-3.0-or-later - -COM_MOKOOG="MokoOpenGraph" -COM_MOKOOG_TAGS_TITLE="MokoOpenGraph - Tag Manager" -COM_MOKOOG_SUBMENU_TAGS="Tags" -COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." -COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" -COM_MOKOOG_AUTO_GENERATED="auto-generated" - -COM_MOKOOG_HEADING_CONTENT_TYPE="Content Type" -COM_MOKOOG_HEADING_CONTENT_ID="Content ID" -COM_MOKOOG_HEADING_OG_TITLE="OG Title" -COM_MOKOOG_HEADING_IMAGE="Image" -COM_MOKOOG_HEADING_MODIFIED="Modified" diff --git a/src/packages/com_mokoog/src/Table/TagTable.php b/src/packages/com_mokoog/src/Table/TagTable.php deleted file mode 100644 index 32817a2..0000000 --- a/src/packages/com_mokoog/src/Table/TagTable.php +++ /dev/null @@ -1,51 +0,0 @@ -<?php - -/** - * @package MokoOpenGraph - * @subpackage com_mokoog - * @author Moko Consulting <hello@mokoconsulting.tech> - * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. - * @license GNU General Public License version 3 or later; see LICENSE - */ - -namespace Joomla\Component\MokoOG\Administrator\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 - */ - public function check(): bool - { - if (empty($this->content_type)) { - $this->setError('Content type is required.'); - - return false; - } - - if (empty($this->content_id)) { - $this->setError('Content ID is required.'); - - return false; - } - - return true; - } -} diff --git a/src/packages/com_mokoog/tmpl/tags/default.php b/src/packages/com_mokoog/tmpl/tags/default.php deleted file mode 100644 index cfec7f7..0000000 --- a/src/packages/com_mokoog/tmpl/tags/default.php +++ /dev/null @@ -1,109 +0,0 @@ -<?php - -/** - * @package MokoOpenGraph - * @subpackage com_mokoog - * @author Moko Consulting <hello@mokoconsulting.tech> - * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. - * @license GNU General Public License version 3 or later; see LICENSE - */ - -defined('_JEXEC') or die; - -use Joomla\CMS\HTML\HTMLHelper; -use Joomla\CMS\Language\Text; -use Joomla\CMS\Layout\LayoutHelper; -use Joomla\CMS\Router\Route; - -/** @var \Joomla\Component\MokoOG\Administrator\View\Tags\HtmlView $this */ - -?> -<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm"> - <div class="row"> - <div class="col-md-12"> - <div id="j-main-container" class="j-main-container"> - - <?php if (empty($this->items)) : ?> - <div class="alert alert-info"> - <span class="icon-info-circle" aria-hidden="true"></span> - <?php echo Text::_('COM_MOKOOG_NO_TAGS'); ?> - </div> - <?php else : ?> - <table class="table" id="tagList"> - <caption class="visually-hidden"> - <?php echo Text::_('COM_MOKOOG_TABLE_CAPTION'); ?> - </caption> - <thead> - <tr> - <td class="w-1 text-center"> - <?php echo HTMLHelper::_('grid.checkall'); ?> - </td> - <th scope="col"> - <?php echo Text::_('COM_MOKOOG_HEADING_CONTENT_TYPE'); ?> - </th> - <th scope="col"> - <?php echo Text::_('COM_MOKOOG_HEADING_CONTENT_ID'); ?> - </th> - <th scope="col"> - <?php echo Text::_('COM_MOKOOG_HEADING_OG_TITLE'); ?> - </th> - <th scope="col" class="w-10"> - <?php echo Text::_('COM_MOKOOG_HEADING_IMAGE'); ?> - </th> - <th scope="col" class="w-10"> - <?php echo Text::_('JSTATUS'); ?> - </th> - <th scope="col" class="w-10"> - <?php echo Text::_('COM_MOKOOG_HEADING_MODIFIED'); ?> - </th> - <th scope="col" class="w-5"> - <?php echo Text::_('JGRID_HEADING_ID'); ?> - </th> - </tr> - </thead> - <tbody> - <?php foreach ($this->items as $i => $item) : ?> - <tr> - <td class="text-center"> - <?php echo HTMLHelper::_('grid.id', $i, $item->id); ?> - </td> - <td> - <?php echo $this->escape($item->content_type); ?> - </td> - <td> - <?php echo (int) $item->content_id; ?> - </td> - <td> - <?php echo $this->escape($item->og_title ?: '(' . Text::_('COM_MOKOOG_AUTO_GENERATED') . ')'); ?> - </td> - <td> - <?php if ($item->og_image) : ?> - <span class="icon-image" aria-hidden="true" title="<?php echo $this->escape($item->og_image); ?>"></span> - <?php else : ?> - <span class="icon-minus-circle text-muted" aria-hidden="true"></span> - <?php endif; ?> - </td> - <td> - <?php echo $item->published ? Text::_('JPUBLISHED') : Text::_('JUNPUBLISHED'); ?> - </td> - <td> - <?php echo HTMLHelper::_('date', $item->modified, Text::_('DATE_FORMAT_LC4')); ?> - </td> - <td> - <?php echo (int) $item->id; ?> - </td> - </tr> - <?php endforeach; ?> - </tbody> - </table> - - <?php echo $this->pagination->getListFooter(); ?> - <?php endif; ?> - - <input type="hidden" name="task" value=""> - <input type="hidden" name="boxchecked" value="0"> - <?php echo HTMLHelper::_('form.token'); ?> - </div> - </div> - </div> -</form> diff --git a/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini b/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini deleted file mode 100644 index 6ea916c..0000000 --- a/src/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini +++ /dev/null @@ -1,15 +0,0 @@ -; MokoOpenGraph - 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." diff --git a/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini b/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini deleted file mode 100644 index 6ea916c..0000000 --- a/src/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini +++ /dev/null @@ -1,15 +0,0 @@ -; MokoOpenGraph - 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." diff --git a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini b/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini deleted file mode 100644 index 4983711..0000000 --- a/src/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini +++ /dev/null @@ -1,25 +0,0 @@ -; MokoOpenGraph - 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_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_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." diff --git a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini b/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini deleted file mode 100644 index 4983711..0000000 --- a/src/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini +++ /dev/null @@ -1,25 +0,0 @@ -; MokoOpenGraph - 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_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_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." diff --git a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php b/src/packages/plg_system_mokoog/src/Extension/MokoOG.php deleted file mode 100644 index 818e263..0000000 --- a/src/packages/plg_system_mokoog/src/Extension/MokoOG.php +++ /dev/null @@ -1,260 +0,0 @@ -<?php - -/** - * @package MokoOpenGraph - * @subpackage plg_system_mokoog - * @author Moko Consulting <hello@mokoconsulting.tech> - * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. - * @license GNU General Public License version 3 or later; see LICENSE - */ - -namespace Joomla\Plugin\System\MokoOG\Extension; - -defined('_JEXEC') or die; - -use Joomla\CMS\Factory; -use Joomla\CMS\Plugin\CMSPlugin; -use Joomla\CMS\Uri\Uri; -use Joomla\Event\Event; -use Joomla\Event\SubscriberInterface; - -final class MokoOG extends CMSPlugin implements SubscriberInterface -{ - /** - * @var bool - */ - protected $autoloadLanguage = true; - - /** - * Returns the events this plugin subscribes to. - * - * @return array<string, string> - */ - public static function getSubscribedEvents(): array - { - return [ - 'onBeforeCompileHead' => 'onBeforeCompileHead', - ]; - } - - /** - * Inject Open Graph and Twitter Card meta tags before the document head is compiled. - * - * @param Event $event The event object - * - * @return void - */ - public function onBeforeCompileHead(Event $event): void - { - $app = $this->getApplication(); - - // Only run on the site frontend - if (!$app->isClient('site')) { - return; - } - - $doc = $app->getDocument(); - - if ($doc->getType() !== 'html') { - return; - } - - $input = $app->getInput(); - $option = $input->getCmd('option', ''); - $view = $input->getCmd('view', ''); - $id = $input->getInt('id', 0); - - // Try to load custom OG data from the database - $ogData = $this->loadOgData($option, $view, $id); - - // Build tag values — custom overrides auto-generated - $title = $ogData->og_title ?: $doc->getTitle(); - $description = $ogData->og_description ?: $this->buildDescription($doc); - $image = $ogData->og_image ?: $this->findImage($option, $view, $id); - $url = Uri::getInstance()->toString(); - $siteName = $this->params->get('og_site_name', $app->get('sitename', '')); - $type = $ogData->og_type ?: 'article'; - - // Open Graph tags - $doc->setMetaData('og:title', $title, 'property'); - $doc->setMetaData('og:description', $description, 'property'); - $doc->setMetaData('og:url', $url, 'property'); - $doc->setMetaData('og:type', $type, 'property'); - $doc->setMetaData('og:site_name', $siteName, 'property'); - - if ($image) { - $imageUrl = $this->resolveImageUrl($image); - $doc->setMetaData('og:image', $imageUrl, 'property'); - } - - // Facebook App ID - $fbAppId = $this->params->get('fb_app_id', ''); - - if ($fbAppId) { - $doc->setMetaData('fb:app_id', $fbAppId, 'property'); - } - - // Twitter Card tags - $cardType = $this->params->get('twitter_card_type', 'summary_large_image'); - $twitterSite = $this->params->get('twitter_site', ''); - - $doc->setMetaData('twitter:card', $cardType); - $doc->setMetaData('twitter:title', $title); - $doc->setMetaData('twitter:description', $description); - - if ($image) { - $doc->setMetaData('twitter:image', $this->resolveImageUrl($image)); - } - - if ($twitterSite) { - $doc->setMetaData('twitter:site', $twitterSite); - } - } - - /** - * Load custom OG data from the database for the current page. - * - * @param string $option Component option - * @param string $view View name - * @param int $id Content ID - * - * @return object - */ - private function loadOgData(string $option, string $view, int $id): object - { - $empty = (object) [ - 'og_title' => '', - 'og_description' => '', - 'og_image' => '', - 'og_type' => '', - ]; - - if (!$id) { - // Try menu-item-based lookup - $menuItem = $this->getApplication()->getMenu()->getActive(); - - if (!$menuItem) { - return $empty; - } - - return $this->loadOgDataByMenu((int) $menuItem->id) ?: $empty; - } - - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__mokoog_tags')) - ->where($db->quoteName('content_type') . ' = ' . $db->quote($option)) - ->where($db->quoteName('content_id') . ' = ' . (int) $id) - ->where($db->quoteName('published') . ' = 1'); - - $db->setQuery($query); - - return $db->loadObject() ?: $empty; - } - - /** - * Load OG data by menu item ID. - * - * @param int $menuId Menu item ID - * - * @return object|null - */ - private function loadOgDataByMenu(int $menuId): ?object - { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__mokoog_tags')) - ->where($db->quoteName('content_type') . ' = ' . $db->quote('menu')) - ->where($db->quoteName('content_id') . ' = ' . $menuId) - ->where($db->quoteName('published') . ' = 1'); - - $db->setQuery($query); - - return $db->loadObject(); - } - - /** - * Build a description from the document metadata or page content. - * - * @param \Joomla\CMS\Document\HtmlDocument $doc The document - * - * @return string - */ - private function buildDescription($doc): string - { - $description = $doc->getDescription(); - $maxLength = (int) $this->params->get('desc_length', 160); - - if ($this->params->get('strip_html', 1)) { - $description = strip_tags($description); - } - - $description = trim(preg_replace('/\s+/', ' ', $description)); - - if (\strlen($description) > $maxLength) { - $description = mb_substr($description, 0, $maxLength - 3) . '...'; - } - - return $description; - } - - /** - * Attempt to find the first image for the given content. - * - * @param string $option Component option - * @param string $view View name - * @param int $id Content ID - * - * @return string - */ - private function findImage(string $option, string $view, int $id): string - { - if (!$this->params->get('auto_generate', 1)) { - return $this->params->get('default_image', ''); - } - - // For Joomla articles, look at the intro/full image fields - if ($option === 'com_content' && $id > 0) { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select($db->quoteName('images')) - ->from($db->quoteName('#__content')) - ->where($db->quoteName('id') . ' = ' . (int) $id); - - $db->setQuery($query); - $images = $db->loadResult(); - - if ($images) { - $imagesData = json_decode($images, true); - - if (!empty($imagesData['image_fulltext'])) { - return $imagesData['image_fulltext']; - } - - if (!empty($imagesData['image_intro'])) { - return $imagesData['image_intro']; - } - } - } - - return $this->params->get('default_image', ''); - } - - /** - * Resolve a relative image path to a full URL. - * - * @param string $image Image path - * - * @return string - */ - private function resolveImageUrl(string $image): string - { - if (str_starts_with($image, 'http://') || str_starts_with($image, 'https://')) { - return $image; - } - - return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/'); - } -} diff --git a/src/script.php b/src/script.php deleted file mode 100644 index c5a505f..0000000 --- a/src/script.php +++ /dev/null @@ -1,78 +0,0 @@ -<?php - -/** - * @package MokoOpenGraph - * @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\Installer\InstallerAdapter; -use Joomla\CMS\Language\Text; - -class Pkg_MokoOGInstallerScript -{ - /** - * Minimum Joomla version required - * - * @var string - */ - protected $minimumJoomla = '4.0.0'; - - /** - * Minimum PHP version required - * - * @var string - */ - protected $minimumPhp = '8.1.0'; - - /** - * Called before any install/update/uninstall action. - * - * @param string $type Action type (install, update, uninstall) - * @param InstallerAdapter $parent Installer adapter - * - * @return bool - */ - public function preflight(string $type, InstallerAdapter $parent): bool - { - if (version_compare(PHP_VERSION, $this->minimumPhp, '<')) { - Factory::getApplication()->enqueueMessage( - Text::sprintf('PKG_MOKOOG_PHP_VERSION_ERROR', $this->minimumPhp), - 'error' - ); - - return false; - } - - return true; - } - - /** - * Called after install/update. - * - * @param string $type Action type - * @param InstallerAdapter $parent Installer adapter - * - * @return void - */ - public function postflight(string $type, InstallerAdapter $parent): void - { - if ($type === 'install') { - // Enable the system plugin automatically on fresh install - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->update($db->quoteName('#__extensions')) - ->set($db->quoteName('enabled') . ' = 1') - ->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) - ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) - ->where($db->quoteName('element') . ' = ' . $db->quote('mokoog')); - - $db->setQuery($query); - $db->execute(); - } - } -}