Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 281e742b54 | |||
| 8de243b181 | |||
| 9793bd4031 | |||
| a9fc5d2cf1 | |||
| f1c6eb8f6e | |||
| a578ac3bb3 | |||
| cf783c6b83 | |||
| bc6ce4397f | |||
| 49d644566a | |||
| cbebaecc22 | |||
| e7b0af1fca | |||
| 2088b3f13f | |||
| f649858fcd | |||
| 7a38025b5e | |||
| e530ca821e | |||
| 872074cd5b | |||
| c871b7d30d | |||
| 641eee753a | |||
| 44d9daf3bc | |||
| 96eea6060f | |||
| aeea65423c | |||
| b4d5b73d15 | |||
| e939e90733 | |||
| d4c22ebdbf | |||
| 5724a1545e | |||
| a04dbfd732 | |||
| bc06710fdd | |||
| 07b296db61 | |||
| 6a0ee812d8 | |||
| fcfa6838e5 | |||
| 908e1d3e1b | |||
| 9539bb44c2 | |||
| 5b29690d34 | |||
| 881bb0a2ae | |||
| e9b34522d3 | |||
| 9aeb588937 | |||
| 9cdc7915a3 | |||
| 72ffaded49 | |||
| 23f6fe12a0 | |||
| 4c1d630673 | |||
| 560c7458c6 | |||
| e39b617464 | |||
| dac22fdcc4 |
@@ -156,6 +156,7 @@ vendor/
|
||||
composer.lock
|
||||
*.phar
|
||||
codeception.phar
|
||||
.phpunit.cache/
|
||||
.phpunit.result.cache
|
||||
.php_cs.cache
|
||||
.php-cs-fixer.cache
|
||||
|
||||
@@ -30,6 +30,15 @@ on:
|
||||
types: [opened, closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '.mokogitea/workflows/**'
|
||||
- '*.md'
|
||||
- 'wiki/**'
|
||||
- '.editorconfig'
|
||||
- '.gitignore'
|
||||
- '.gitattributes'
|
||||
- '.gitmessage'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Deploy
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
|
||||
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
|
||||
# VERSION: 04.07.00
|
||||
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
|
||||
|
||||
name: "Universal: Deploy to Dev (Manual)"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
clear_remote:
|
||||
description: 'Delete all remote files before uploading'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: SFTP Deploy to Dev
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
php -v && composer --version
|
||||
|
||||
- name: Setup MokoStandards tools
|
||||
env:
|
||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||
/tmp/mokostandards-api 2>/dev/null || true
|
||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Check FTP configuration
|
||||
id: check
|
||||
env:
|
||||
HOST: ${{ vars.DEV_FTP_HOST }}
|
||||
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
|
||||
PORT: ${{ vars.DEV_FTP_PORT }}
|
||||
run: |
|
||||
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
|
||||
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "host=$HOST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REMOTE="${PATH_VAR%/}"
|
||||
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
[ -z "$PORT" ] && PORT="22"
|
||||
echo "port=$PORT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy via SFTP
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
env:
|
||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
|
||||
|
||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
||||
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
|
||||
> /tmp/sftp-config.json
|
||||
|
||||
if [ -n "$SFTP_KEY" ]; then
|
||||
echo "$SFTP_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
||||
else
|
||||
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
|
||||
fi
|
||||
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
|
||||
|
||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
|
||||
else
|
||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
fi
|
||||
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
|
||||
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.03.01
|
||||
# VERSION: 01.04.08
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
name: "Universal: Security Audit"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Composer audit
|
||||
if: hashFiles('composer.lock') != ''
|
||||
run: |
|
||||
echo "=== Composer Security Audit ==="
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
||||
fi
|
||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
||||
RESULT=$?
|
||||
if [ $RESULT -ne 0 ]; then
|
||||
echo "::warning::Composer vulnerabilities found"
|
||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No known vulnerabilities in composer dependencies"
|
||||
fi
|
||||
|
||||
- name: NPM audit
|
||||
if: hashFiles('package-lock.json') != ''
|
||||
run: |
|
||||
echo "=== NPM Security Audit ==="
|
||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
||||
echo "No known vulnerabilities in npm dependencies"
|
||||
else
|
||||
echo "::warning::NPM vulnerabilities found"
|
||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Notify on vulnerabilities
|
||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
||||
-H "Tags: lock,warning" \
|
||||
-H "Priority: high" \
|
||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
+31
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
<!-- VERSION: 01.03.01 -->
|
||||
<!-- VERSION: 01.04.08 -->
|
||||
|
||||
All notable changes to MokoSuiteOpenGraph will be documented in this file.
|
||||
|
||||
@@ -15,6 +15,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- Fix multilingual data corruption in content plugin load/save (#41)
|
||||
|
||||
### Added
|
||||
- Fediverse/Mastodon `fediverse:creator` meta tag — first extension on any CMS to support this (#57)
|
||||
- Live character count indicators on OG title, OG description, SEO title, meta description fields with color-coded warnings (#58)
|
||||
- LinkedIn social preview card in article/menu editor alongside Facebook and Twitter/X previews (#61)
|
||||
- `og:video` meta tag support with per-article video URL field, auto-detect MIME type for YouTube/Vimeo/direct files (#59)
|
||||
- Pinterest rich pin tags: `article:tag` from Joomla content tags, `product:availability` from MokoSuiteShop stock (#60)
|
||||
- FAQ JSON-LD schema with auto-detection from article h3/h4 headings (#62)
|
||||
- HowTo JSON-LD schema with auto-detection from ordered lists (#63)
|
||||
- Event JSON-LD schema with per-article event fields (dates, venue, tickets) (#64)
|
||||
- LocalBusiness JSON-LD schema with global plugin configuration (#65)
|
||||
- Recipe JSON-LD schema with per-article fields (times, ingredients, nutrition) (#66)
|
||||
- VideoObject JSON-LD schema for articles with video URLs (#67)
|
||||
- SEO content scoring panel with 7 checks and pass/fail indicators (#68)
|
||||
- Discord, Mastodon, and Slack social preview cards in editor (#69)
|
||||
- Custom JSON-LD schema builder — per-article textarea for any schema.org type (#70)
|
||||
- AI-powered meta tag generation with Claude and OpenAI API support (#71)
|
||||
- XML sitemap generation on article save, respects noindex directives (#72)
|
||||
- OG coverage dashboard in tag manager with coverage percentage (#73)
|
||||
- Per-platform image resizing: Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400 (#74)
|
||||
- PHPUnit test suite with 16 unit tests for JsonLdBuilder (#75)
|
||||
- OpenAPI 3.0 specification for REST API (#80)
|
||||
- Site-wide default OG title and description plugin parameters
|
||||
- Discord embed color via `theme-color` meta tag (color picker in plugin config)
|
||||
- LinkedIn article tags: `article:published_time`, `article:modified_time`, `article:author`
|
||||
@@ -40,6 +60,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- Facebook App ID and Telegram channel support
|
||||
- Database table `#__mokoog_tags` with multilingual unique key
|
||||
|
||||
### Fixed
|
||||
- Add exception logging to BatchController batch skip (#76)
|
||||
- Align form maxlength attributes with DB schema limits (#77)
|
||||
- Add `strip_tags()` input sanitization on OG text fields (#79)
|
||||
- Only emit `og:video:secure_url` for HTTPS URLs
|
||||
- Only emit `og:video:width/height` for direct files, not embeds
|
||||
- Consolidate duplicate MokoSuiteShop product blocks
|
||||
- Fix stale `com_virtuemart` reference in SQL comment
|
||||
- Use component language keys for og_video field in tag.xml
|
||||
|
||||
### Changed
|
||||
- Consolidated article DB queries into single cached lookup — 5 queries reduced to 1 (#38)
|
||||
- Dynamic `og:image:width`/`og:image:height` from actual image dimensions instead of hardcoded (#39)
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
# Makefile for Joomla Extensions
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# MokoJoomOpenGraph — Open Graph & social sharing meta tag management
|
||||
|
||||
# ==============================================================================
|
||||
# CONFIGURATION - Customize these for your extension
|
||||
# ==============================================================================
|
||||
|
||||
# Extension Configuration
|
||||
EXTENSION_NAME := mokoog
|
||||
EXTENSION_TYPE := package
|
||||
# Options: module, plugin, component, package, template
|
||||
EXTENSION_VERSION := 1.0.0
|
||||
|
||||
# Module Configuration (for modules only)
|
||||
MODULE_TYPE := site
|
||||
# Options: site, admin
|
||||
|
||||
# Plugin Configuration (for plugins only)
|
||||
PLUGIN_GROUP := system
|
||||
# Options: system, content, user, authentication, etc.
|
||||
|
||||
# Directories
|
||||
SRC_DIR := src
|
||||
BUILD_DIR := build
|
||||
DIST_DIR := dist
|
||||
DOCS_DIR := docs
|
||||
|
||||
# Joomla Installation (for local testing - customize paths)
|
||||
JOOMLA_ROOT := /var/www/html/joomla
|
||||
JOOMLA_VERSION := 4
|
||||
|
||||
# Tools
|
||||
PHP := php
|
||||
COMPOSER := composer
|
||||
NPM := npm
|
||||
PHPCS := vendor/bin/phpcs
|
||||
PHPCBF := vendor/bin/phpcbf
|
||||
PHPUNIT := vendor/bin/phpunit
|
||||
ZIP := zip
|
||||
|
||||
# Coding Standards
|
||||
PHPCS_STANDARD := Joomla
|
||||
|
||||
# Colors for output
|
||||
COLOR_RESET := \033[0m
|
||||
COLOR_GREEN := \033[32m
|
||||
COLOR_YELLOW := \033[33m
|
||||
COLOR_BLUE := \033[34m
|
||||
COLOR_RED := \033[31m
|
||||
|
||||
# ==============================================================================
|
||||
# TARGETS
|
||||
# ==============================================================================
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help message
|
||||
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)"
|
||||
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
|
||||
@echo ""
|
||||
@echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)"
|
||||
@echo ""
|
||||
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
|
||||
@echo ""
|
||||
|
||||
.PHONY: install-deps
|
||||
install-deps: ## Install all dependencies (Composer + npm)
|
||||
@echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)"
|
||||
@if [ -f "composer.json" ]; then \
|
||||
$(COMPOSER) install; \
|
||||
echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \
|
||||
fi
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run PHP linter (syntax check)
|
||||
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
|
||||
@find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \
|
||||
-exec $(PHP) -l {} \; | grep -v "No syntax errors" || true
|
||||
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
|
||||
|
||||
.PHONY: phpcs
|
||||
phpcs: ## Run PHP CodeSniffer (Joomla standards)
|
||||
@echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)"
|
||||
@if [ -f "$(PHPCS)" ]; then \
|
||||
$(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \
|
||||
else \
|
||||
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \
|
||||
fi
|
||||
|
||||
.PHONY: validate
|
||||
validate: lint phpcs ## Run all validation checks
|
||||
@echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)"
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Clean build artifacts
|
||||
@echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)"
|
||||
@rm -rf $(BUILD_DIR) $(DIST_DIR)
|
||||
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
|
||||
|
||||
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
|
||||
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
|
||||
|
||||
.PHONY: minify
|
||||
minify: ## Minify CSS/JS assets
|
||||
@echo "Minifying assets..."
|
||||
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
|
||||
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
|
||||
elif [ -f "scripts/minify.js" ]; then \
|
||||
node scripts/minify.js; \
|
||||
else \
|
||||
echo "No minify script found"; \
|
||||
fi
|
||||
|
||||
.PHONY: build
|
||||
build: clean validate minify ## Build extension package
|
||||
@echo "$(COLOR_BLUE)Building Joomla extension package...$(COLOR_RESET)"
|
||||
@mkdir -p $(DIST_DIR) $(BUILD_DIR)
|
||||
|
||||
# Determine package prefix based on extension type
|
||||
@case "$(EXTENSION_TYPE)" in \
|
||||
module) \
|
||||
PACKAGE_PREFIX="mod_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
plugin) \
|
||||
PACKAGE_PREFIX="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
component) \
|
||||
PACKAGE_PREFIX="com_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
package) \
|
||||
PACKAGE_PREFIX="pkg_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
template) \
|
||||
PACKAGE_PREFIX="tpl_$(EXTENSION_NAME)"; \
|
||||
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
|
||||
;; \
|
||||
*) \
|
||||
echo "$(COLOR_RED)✗ Unknown extension type: $(EXTENSION_TYPE)$(COLOR_RESET)"; \
|
||||
exit 1; \
|
||||
;; \
|
||||
esac; \
|
||||
\
|
||||
mkdir -p "$$BUILD_TARGET"; \
|
||||
\
|
||||
echo "Building $$PACKAGE_PREFIX..."; \
|
||||
\
|
||||
rsync -av --progress \
|
||||
--exclude='$(BUILD_DIR)' \
|
||||
--exclude='$(DIST_DIR)' \
|
||||
--exclude='.git*' \
|
||||
--exclude='vendor/' \
|
||||
--exclude='node_modules/' \
|
||||
--exclude='tests/' \
|
||||
--exclude='Makefile' \
|
||||
--exclude='composer.json' \
|
||||
--exclude='composer.lock' \
|
||||
--exclude='package.json' \
|
||||
--exclude='package-lock.json' \
|
||||
--exclude='phpunit.xml' \
|
||||
--exclude='*.md' \
|
||||
--exclude='.editorconfig' \
|
||||
. "$$BUILD_TARGET/"; \
|
||||
\
|
||||
cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip" "$${PACKAGE_PREFIX}"; \
|
||||
\
|
||||
echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
|
||||
|
||||
.PHONY: package
|
||||
package: build ## Alias for build
|
||||
@echo "$(COLOR_GREEN)✓ Package ready for distribution$(COLOR_RESET)"
|
||||
|
||||
.PHONY: release
|
||||
release: validate build ## Create a release (validate + build)
|
||||
@echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)"
|
||||
|
||||
.PHONY: version
|
||||
version: ## Display version information
|
||||
@echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)"
|
||||
@echo " Name: $(EXTENSION_NAME)"
|
||||
@echo " Type: $(EXTENSION_TYPE)"
|
||||
@echo " Version: $(EXTENSION_VERSION)"
|
||||
|
||||
.PHONY: security-check
|
||||
security-check: ## Run security checks on dependencies
|
||||
@echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)"
|
||||
@if [ -f "composer.json" ]; then \
|
||||
$(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \
|
||||
fi
|
||||
|
||||
.PHONY: all
|
||||
all: install-deps validate build ## Run complete build pipeline
|
||||
@echo "$(COLOR_GREEN)✓ Complete build pipeline finished$(COLOR_RESET)"
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoSuiteOpenGraph
|
||||
|
||||
<!-- VERSION: 01.03.01 -->
|
||||
<!-- VERSION: 01.04.08 -->
|
||||
|
||||
Open Graph, Twitter Card, and social sharing meta tag management for Joomla 4/5/6.
|
||||
|
||||
@@ -16,6 +16,9 @@ MokoSuiteOpenGraph gives you full control over how your Joomla content appears w
|
||||
- **LinkedIn** — `article:published_time`, `article:modified_time`, `article:author`
|
||||
- **Discord** — Custom embed color via `theme-color` meta tag
|
||||
- **Telegram** — `telegram:channel` for link previews
|
||||
- **Mastodon/Fediverse** — `fediverse:creator` for author attribution (first extension on any CMS)
|
||||
- **Pinterest** — Rich pin tags: `article:tag`, `product:availability`, `product:price`
|
||||
- **og:video** — Per-article video URLs with auto MIME type detection (YouTube/Vimeo/direct)
|
||||
- **Facebook** — `fb:app_id` support, `og:image:width`/`og:image:height` for instant previews
|
||||
|
||||
### Content Management
|
||||
@@ -31,7 +34,8 @@ MokoSuiteOpenGraph gives you full control over how your Joomla content appears w
|
||||
- **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
|
||||
- **JSON-LD structured data** — Article, Product, WebPage, BreadcrumbList, Organization, FAQ, HowTo, Event, Recipe, LocalBusiness, VideoObject, and custom schemas
|
||||
- **SEO content scoring** — 7-check analysis panel with pass/fail indicators in the editor
|
||||
|
||||
### Admin Tools
|
||||
- **Tag manager dashboard** — View and manage all OG records centrally
|
||||
@@ -39,13 +43,20 @@ MokoSuiteOpenGraph gives you full control over how your Joomla content appears w
|
||||
- **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
|
||||
- **Live preview** — Real-time Facebook, Twitter/X, LinkedIn, Discord, Mastodon, and Slack card previews in the editor
|
||||
- **Character count indicators** — Green/yellow/red warnings on OG and SEO text fields
|
||||
- **OG coverage dashboard** — Coverage percentage and missing field counts
|
||||
- **AI meta generation** — Generate OG titles and descriptions with Claude or OpenAI
|
||||
|
||||
### Developer Features
|
||||
- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`)
|
||||
- **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta
|
||||
- **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags
|
||||
- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630
|
||||
- **Per-platform image resizing** — Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400
|
||||
- **XML sitemap** — Auto-generates sitemap.xml on article save, respects noindex
|
||||
- **OpenAPI spec** — Full REST API documentation at `openapi.yaml`
|
||||
- **PHPUnit tests** — 16 unit tests for JsonLdBuilder schema outputs
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -63,6 +74,11 @@ Navigate to **Extensions → Plugins → System - MokoSuiteOpenGraph** to config
|
||||
- Facebook App ID
|
||||
- Discord embed color
|
||||
- Telegram channel
|
||||
- Fediverse/Mastodon creator handle
|
||||
- LocalBusiness schema (address, phone, hours, geo)
|
||||
- XML sitemap generation
|
||||
- AI meta generation (Claude/OpenAI API key)
|
||||
- Per-platform image resizing
|
||||
- Auto-generation, image resize, JSON-LD, and description length settings
|
||||
|
||||
## License
|
||||
|
||||
+16
-2
@@ -15,9 +15,23 @@
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"joomla/coding-standards": "^3.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"joomla/coding-standards": "^3.0"
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Joomla\\Plugin\\System\\MokoOG\\": "source/packages/plg_system_mokoog/src/",
|
||||
"Joomla\\Plugin\\Content\\MokoOG\\": "source/packages/plg_content_mokoog/src/",
|
||||
"Joomla\\Plugin\\WebServices\\MokoOG\\": "source/packages/plg_webservices_mokoog/src/",
|
||||
"Joomla\\Component\\MokoOG\\Administrator\\": "source/packages/com_mokoog/src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Mokoconsulting\\MokoOG\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "alpha",
|
||||
"prefer-stable": true,
|
||||
|
||||
+668
@@ -0,0 +1,668 @@
|
||||
openapi: 3.0.3
|
||||
|
||||
info:
|
||||
title: MokoSuiteOpenGraph API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
REST API for managing Open Graph, SEO meta, and structured-data tags in
|
||||
Joomla via the MokoSuiteOpenGraph extension.
|
||||
|
||||
The API follows Joomla's Web Services conventions and returns responses in
|
||||
[JSON:API](https://jsonapi.org/) format. All endpoints require
|
||||
authentication via a Joomla API token.
|
||||
contact:
|
||||
name: Moko Consulting
|
||||
email: hello@mokoconsulting.tech
|
||||
license:
|
||||
name: GPL-3.0-or-later
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
||||
servers:
|
||||
- url: /api/index.php/v1
|
||||
description: Joomla Web Services API
|
||||
|
||||
security:
|
||||
- apiToken: []
|
||||
|
||||
tags:
|
||||
- name: Tags
|
||||
description: CRUD operations for Open Graph tag records
|
||||
|
||||
paths:
|
||||
/mokoog/tags:
|
||||
get:
|
||||
operationId: listTags
|
||||
summary: List OG tags
|
||||
description: |
|
||||
Returns a paginated collection of OG tag records. Supports filtering
|
||||
by content type, published state, and language.
|
||||
tags: [Tags]
|
||||
parameters:
|
||||
- name: "filter[content_type]"
|
||||
in: query
|
||||
description: Filter by content type (e.g. `com_content`, `menu`, `com_mokoshop`)
|
||||
schema:
|
||||
type: string
|
||||
example: com_content
|
||||
- name: "filter[content_id]"
|
||||
in: query
|
||||
description: Filter by content ID
|
||||
schema:
|
||||
type: integer
|
||||
example: 42
|
||||
- name: "filter[published]"
|
||||
in: query
|
||||
description: Filter by published state
|
||||
schema:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
- name: "filter[language]"
|
||||
in: query
|
||||
description: Filter by language tag (e.g. `en-GB`, `*`)
|
||||
schema:
|
||||
type: string
|
||||
example: "*"
|
||||
- name: "filter[search]"
|
||||
in: query
|
||||
description: Free-text search across tag fields
|
||||
schema:
|
||||
type: string
|
||||
- name: "page[offset]"
|
||||
in: query
|
||||
description: Number of records to skip (pagination offset)
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
- name: "page[limit]"
|
||||
in: query
|
||||
description: Maximum number of records to return
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 25
|
||||
- name: "list[fullordering]"
|
||||
in: query
|
||||
description: Sort order for results
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- a.id ASC
|
||||
- a.id DESC
|
||||
- a.og_title ASC
|
||||
- a.og_title DESC
|
||||
- a.modified ASC
|
||||
- a.modified DESC
|
||||
default: a.modified DESC
|
||||
responses:
|
||||
"200":
|
||||
description: A JSON:API collection of OG tags
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagCollection"
|
||||
example:
|
||||
links:
|
||||
self: "/api/index.php/v1/mokoog/tags"
|
||||
data:
|
||||
- type: tags
|
||||
id: "1"
|
||||
attributes:
|
||||
content_type: com_content
|
||||
content_id: 42
|
||||
og_title: "My Article Title"
|
||||
og_description: "A brief description for social sharing."
|
||||
og_image: "images/mokoog/og-banner.jpg"
|
||||
og_type: article
|
||||
seo_title: "My Article | Example Site"
|
||||
meta_description: "A brief meta description for search engines."
|
||||
robots: "index, follow"
|
||||
canonical_url: "https://example.com/my-article"
|
||||
language: "*"
|
||||
published: 1
|
||||
created: "2026-06-01T12:00:00+00:00"
|
||||
modified: "2026-06-15T08:30:00+00:00"
|
||||
meta:
|
||||
total-pages: 1
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
post:
|
||||
operationId: createTag
|
||||
summary: Create an OG tag
|
||||
description: |
|
||||
Creates a new OG tag record. The combination of `content_type`,
|
||||
`content_id`, and `language` must be unique.
|
||||
tags: [Tags]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagCreateRequest"
|
||||
example:
|
||||
content_type: com_content
|
||||
content_id: 42
|
||||
og_title: "My Article Title"
|
||||
og_description: "A brief description for social sharing."
|
||||
og_image: "images/mokoog/og-banner.jpg"
|
||||
og_type: article
|
||||
language: "*"
|
||||
published: 1
|
||||
responses:
|
||||
"200":
|
||||
description: The created tag
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagDocument"
|
||||
example:
|
||||
links:
|
||||
self: "/api/index.php/v1/mokoog/tags/1"
|
||||
data:
|
||||
type: tags
|
||||
id: "1"
|
||||
attributes:
|
||||
content_type: com_content
|
||||
content_id: 42
|
||||
og_title: "My Article Title"
|
||||
og_description: "A brief description for social sharing."
|
||||
og_image: "images/mokoog/og-banner.jpg"
|
||||
og_type: article
|
||||
seo_title: ""
|
||||
meta_description: ""
|
||||
robots: ""
|
||||
canonical_url: ""
|
||||
language: "*"
|
||||
published: 1
|
||||
created: "2026-06-23T10:00:00+00:00"
|
||||
modified: "2026-06-23T10:00:00+00:00"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
|
||||
/mokoog/tags/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TagId"
|
||||
|
||||
get:
|
||||
operationId: getTag
|
||||
summary: Get a single OG tag
|
||||
tags: [Tags]
|
||||
responses:
|
||||
"200":
|
||||
description: A single OG tag resource
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagDocument"
|
||||
example:
|
||||
links:
|
||||
self: "/api/index.php/v1/mokoog/tags/1"
|
||||
data:
|
||||
type: tags
|
||||
id: "1"
|
||||
attributes:
|
||||
content_type: com_content
|
||||
content_id: 42
|
||||
og_title: "My Article Title"
|
||||
og_description: "A brief description for social sharing."
|
||||
og_image: "images/mokoog/og-banner.jpg"
|
||||
og_type: article
|
||||
seo_title: "My Article | Example Site"
|
||||
meta_description: "A brief meta description for search engines."
|
||||
robots: "index, follow"
|
||||
canonical_url: "https://example.com/my-article"
|
||||
language: "*"
|
||||
published: 1
|
||||
created: "2026-06-01T12:00:00+00:00"
|
||||
modified: "2026-06-15T08:30:00+00:00"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
patch:
|
||||
operationId: updateTag
|
||||
summary: Update an OG tag
|
||||
description: Partially updates an existing OG tag. Only supplied fields are changed.
|
||||
tags: [Tags]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagUpdateRequest"
|
||||
example:
|
||||
og_title: "Updated Title"
|
||||
og_description: "Updated social description."
|
||||
published: 0
|
||||
responses:
|
||||
"200":
|
||||
description: The updated tag
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagDocument"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
delete:
|
||||
operationId: deleteTag
|
||||
summary: Delete an OG tag
|
||||
tags: [Tags]
|
||||
responses:
|
||||
"204":
|
||||
description: Tag deleted successfully
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
/mokoog/lookup/{content_type}/{content_id}:
|
||||
get:
|
||||
operationId: lookupTag
|
||||
summary: Look up an OG tag by content type and content ID
|
||||
description: |
|
||||
Resolves an OG tag by its `content_type` and `content_id` pair and
|
||||
returns the full tag resource. This is a convenience endpoint that
|
||||
avoids the caller needing to know the internal tag ID.
|
||||
tags: [Tags]
|
||||
parameters:
|
||||
- name: content_type
|
||||
in: path
|
||||
required: true
|
||||
description: |
|
||||
The content type identifier (e.g. `com_content`, `menu`,
|
||||
`com_mokoshop`). Must match the pattern `[a-z][a-z0-9_.]*`.
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9_.]*$"
|
||||
example: com_content
|
||||
- name: content_id
|
||||
in: path
|
||||
required: true
|
||||
description: The content item ID
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 42
|
||||
responses:
|
||||
"200":
|
||||
description: The matching OG tag resource
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagDocument"
|
||||
"400":
|
||||
description: Missing or invalid content_type / content_id
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Bad Request
|
||||
status: 400
|
||||
detail: "content_type and content_id are required"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
description: No OG tag found for the given content_type and content_id
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Not Found
|
||||
status: 404
|
||||
detail: "OG tag not found for com_content:42"
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
apiToken:
|
||||
type: apiKey
|
||||
name: X-Joomla-Token
|
||||
in: header
|
||||
description: |
|
||||
Joomla API token. Can also be passed as the `api-token` query
|
||||
parameter. Generate a token from the Joomla administrator panel
|
||||
under Users > Manage > [user] > Joomla API Token tab.
|
||||
|
||||
parameters:
|
||||
TagId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
description: The OG tag record ID
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 1
|
||||
|
||||
schemas:
|
||||
TagAttributes:
|
||||
type: object
|
||||
description: Full set of OG tag attributes returned by the API
|
||||
properties:
|
||||
content_type:
|
||||
type: string
|
||||
description: |
|
||||
Content type identifier (e.g. `com_content`, `menu`,
|
||||
`com_mokoshop`). Must match `[a-z][a-z0-9_.]*`.
|
||||
pattern: "^[a-z][a-z0-9_.]*$"
|
||||
maxLength: 100
|
||||
example: com_content
|
||||
content_id:
|
||||
type: integer
|
||||
description: The ID of the associated content item
|
||||
minimum: 1
|
||||
example: 42
|
||||
og_title:
|
||||
type: string
|
||||
description: Open Graph title (`og:title`)
|
||||
maxLength: 255
|
||||
example: "My Article Title"
|
||||
og_description:
|
||||
type: string
|
||||
description: Open Graph description (`og:description`)
|
||||
example: "A brief description for social sharing."
|
||||
og_image:
|
||||
type: string
|
||||
description: Relative path to the Open Graph image (`og:image`)
|
||||
maxLength: 512
|
||||
example: "images/mokoog/og-banner.jpg"
|
||||
og_type:
|
||||
type: string
|
||||
description: Open Graph type (`og:type`)
|
||||
default: article
|
||||
enum:
|
||||
- article
|
||||
- website
|
||||
- product
|
||||
- profile
|
||||
- book
|
||||
- music.song
|
||||
- music.album
|
||||
- video.movie
|
||||
- video.episode
|
||||
- video.other
|
||||
example: article
|
||||
seo_title:
|
||||
type: string
|
||||
description: SEO page title (used in `<title>` tag)
|
||||
maxLength: 70
|
||||
example: "My Article | Example Site"
|
||||
meta_description:
|
||||
type: string
|
||||
description: Meta description for search engines
|
||||
maxLength: 200
|
||||
example: "A brief meta description for search engines."
|
||||
robots:
|
||||
type: string
|
||||
description: |
|
||||
Comma-separated robots directives. Valid directives: `index`,
|
||||
`noindex`, `follow`, `nofollow`, `none`, `noarchive`,
|
||||
`nosnippet`, `noimageindex`, `max-snippet`, `max-image-preview`.
|
||||
maxLength: 100
|
||||
example: "index, follow"
|
||||
canonical_url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Canonical URL for the page
|
||||
maxLength: 512
|
||||
example: "https://example.com/my-article"
|
||||
language:
|
||||
type: string
|
||||
description: Joomla language tag (`*` for all languages)
|
||||
maxLength: 7
|
||||
default: "*"
|
||||
example: "*"
|
||||
published:
|
||||
type: integer
|
||||
description: Published state (1 = published, 0 = unpublished)
|
||||
enum: [0, 1]
|
||||
default: 1
|
||||
example: 1
|
||||
created:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Record creation timestamp (read-only)
|
||||
readOnly: true
|
||||
example: "2026-06-01T12:00:00+00:00"
|
||||
modified:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Last modification timestamp (read-only)
|
||||
readOnly: true
|
||||
example: "2026-06-15T08:30:00+00:00"
|
||||
|
||||
TagResource:
|
||||
type: object
|
||||
description: A single OG tag in JSON:API resource format
|
||||
required: [type, id, attributes]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [tags]
|
||||
example: tags
|
||||
id:
|
||||
type: string
|
||||
description: The record ID as a string (per JSON:API spec)
|
||||
example: "1"
|
||||
attributes:
|
||||
$ref: "#/components/schemas/TagAttributes"
|
||||
|
||||
TagDocument:
|
||||
type: object
|
||||
description: JSON:API document containing a single tag resource
|
||||
properties:
|
||||
links:
|
||||
type: object
|
||||
properties:
|
||||
self:
|
||||
type: string
|
||||
example: "/api/index.php/v1/mokoog/tags/1"
|
||||
data:
|
||||
$ref: "#/components/schemas/TagResource"
|
||||
|
||||
TagCollection:
|
||||
type: object
|
||||
description: JSON:API document containing a collection of tag resources
|
||||
properties:
|
||||
links:
|
||||
type: object
|
||||
properties:
|
||||
self:
|
||||
type: string
|
||||
example: "/api/index.php/v1/mokoog/tags"
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TagResource"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
total-pages:
|
||||
type: integer
|
||||
description: Total number of pages available
|
||||
example: 1
|
||||
|
||||
TagCreateRequest:
|
||||
type: object
|
||||
description: Request body for creating a new OG tag
|
||||
required:
|
||||
- content_type
|
||||
- content_id
|
||||
properties:
|
||||
content_type:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9_.]*$"
|
||||
maxLength: 100
|
||||
example: com_content
|
||||
content_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 42
|
||||
og_title:
|
||||
type: string
|
||||
maxLength: 255
|
||||
og_description:
|
||||
type: string
|
||||
og_image:
|
||||
type: string
|
||||
maxLength: 512
|
||||
og_type:
|
||||
type: string
|
||||
default: article
|
||||
enum:
|
||||
- article
|
||||
- website
|
||||
- product
|
||||
- profile
|
||||
- book
|
||||
- music.song
|
||||
- music.album
|
||||
- video.movie
|
||||
- video.episode
|
||||
- video.other
|
||||
og_video:
|
||||
type: string
|
||||
format: uri
|
||||
description: Open Graph video URL (`og:video`)
|
||||
maxLength: 512
|
||||
seo_title:
|
||||
type: string
|
||||
maxLength: 70
|
||||
meta_description:
|
||||
type: string
|
||||
maxLength: 200
|
||||
robots:
|
||||
type: string
|
||||
maxLength: 100
|
||||
canonical_url:
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 512
|
||||
language:
|
||||
type: string
|
||||
maxLength: 7
|
||||
default: "*"
|
||||
published:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
default: 1
|
||||
|
||||
TagUpdateRequest:
|
||||
type: object
|
||||
description: |
|
||||
Request body for updating an OG tag. All fields are optional; only
|
||||
supplied fields are modified.
|
||||
properties:
|
||||
og_title:
|
||||
type: string
|
||||
maxLength: 255
|
||||
og_description:
|
||||
type: string
|
||||
og_image:
|
||||
type: string
|
||||
maxLength: 512
|
||||
og_type:
|
||||
type: string
|
||||
enum:
|
||||
- article
|
||||
- website
|
||||
- product
|
||||
- profile
|
||||
- book
|
||||
- music.song
|
||||
- music.album
|
||||
- video.movie
|
||||
- video.episode
|
||||
- video.other
|
||||
og_video:
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 512
|
||||
seo_title:
|
||||
type: string
|
||||
maxLength: 70
|
||||
meta_description:
|
||||
type: string
|
||||
maxLength: 200
|
||||
robots:
|
||||
type: string
|
||||
maxLength: 100
|
||||
canonical_url:
|
||||
type: string
|
||||
format: uri
|
||||
maxLength: 512
|
||||
language:
|
||||
type: string
|
||||
maxLength: 7
|
||||
published:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
description: JSON:API error response
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
example: Not Found
|
||||
status:
|
||||
type: integer
|
||||
example: 404
|
||||
detail:
|
||||
type: string
|
||||
example: "Item not found."
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Invalid request data
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Bad Request
|
||||
status: 400
|
||||
detail: "Content type is required."
|
||||
|
||||
Unauthorized:
|
||||
description: Missing or invalid API token
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Forbidden
|
||||
status: 403
|
||||
detail: "You are not authorised to access this resource."
|
||||
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
example:
|
||||
errors:
|
||||
- title: Not Found
|
||||
status: 404
|
||||
detail: "Item not found."
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache">
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>source/packages</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -30,7 +30,7 @@
|
||||
label="COM_MOKOOG_FIELD_OG_TITLE"
|
||||
description="COM_MOKOOG_FIELD_OG_TITLE_DESC"
|
||||
filter="string"
|
||||
maxlength="70"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="og_description"
|
||||
@@ -39,7 +39,7 @@
|
||||
description="COM_MOKOOG_FIELD_OG_DESCRIPTION_DESC"
|
||||
filter="string"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
maxlength="512"
|
||||
/>
|
||||
<field
|
||||
name="og_image"
|
||||
@@ -60,6 +60,14 @@
|
||||
<option value="product">Product</option>
|
||||
<option value="profile">Profile</option>
|
||||
</field>
|
||||
<field
|
||||
name="og_video"
|
||||
type="url"
|
||||
label="COM_MOKOOG_FIELD_OG_VIDEO"
|
||||
description="COM_MOKOOG_FIELD_OG_VIDEO_DESC"
|
||||
filter="url"
|
||||
validate="url"
|
||||
/>
|
||||
<field
|
||||
name="published"
|
||||
type="list"
|
||||
@@ -77,7 +85,7 @@
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
|
||||
filter="string"
|
||||
maxlength="70"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="meta_description"
|
||||
@@ -86,7 +94,7 @@
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
|
||||
filter="string"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="robots"
|
||||
|
||||
@@ -32,6 +32,8 @@ 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_FIELD_OG_VIDEO="Video URL"
|
||||
COM_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video for social sharing previews. Supports direct video URLs and YouTube/Vimeo links."
|
||||
|
||||
COM_MOKOOG_FILTER_SEARCH="Search OG titles"
|
||||
COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
|
||||
@@ -57,3 +59,10 @@ 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."
|
||||
|
||||
COM_MOKOOG_COVERAGE_TITLE="OG Tag Coverage"
|
||||
COM_MOKOOG_COVERAGE_PERCENT="OG Coverage"
|
||||
COM_MOKOOG_COVERAGE_ARTICLES="%d of %d articles have OG tags"
|
||||
COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title"
|
||||
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
|
||||
COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image"
|
||||
|
||||
@@ -32,6 +32,8 @@ 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_FIELD_OG_VIDEO="Video URL"
|
||||
COM_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video for social sharing previews. Supports direct video URLs and YouTube/Vimeo links."
|
||||
|
||||
COM_MOKOOG_FILTER_SEARCH="Search OG titles"
|
||||
COM_MOKOOG_FILTER_CONTENT_TYPE="Content Type"
|
||||
@@ -57,3 +59,10 @@ 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."
|
||||
|
||||
COM_MOKOOG_COVERAGE_TITLE="OG Tag Coverage"
|
||||
COM_MOKOOG_COVERAGE_PERCENT="OG Coverage"
|
||||
COM_MOKOOG_COVERAGE_ARTICLES="%d of %d articles have OG tags"
|
||||
COM_MOKOOG_COVERAGE_MISSING_TITLE="%d tags missing custom title"
|
||||
COM_MOKOOG_COVERAGE_MISSING_DESC="%d tags missing custom description"
|
||||
COM_MOKOOG_COVERAGE_MISSING_IMAGE="%d tags missing custom image"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokoog</name>
|
||||
<version>01.03.01</version>
|
||||
<version>01.04.08</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokoog_tags` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`content_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'e.g. com_content, menu, com_virtuemart',
|
||||
`content_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'e.g. com_content, menu, com_mokoshop',
|
||||
`content_id` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`og_title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`og_description` TEXT NOT NULL,
|
||||
`og_image` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`og_type` VARCHAR(50) NOT NULL DEFAULT 'article',
|
||||
`og_video` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`event_data` TEXT NULL,
|
||||
`recipe_data` TEXT NULL,
|
||||
`custom_schema` TEXT NULL,
|
||||
`seo_title` VARCHAR(70) NOT NULL DEFAULT '',
|
||||
`meta_description` VARCHAR(200) NOT NULL DEFAULT '',
|
||||
`robots` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
--
|
||||
-- MokoJoomOpenGraph 01.03.00 - Add og_video column
|
||||
--
|
||||
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `og_video` VARCHAR(512) NOT NULL DEFAULT '' AFTER `og_type`;
|
||||
@@ -0,0 +1,6 @@
|
||||
--
|
||||
-- MokoJoomOpenGraph 01.04.00 - Add event_data and recipe_data columns
|
||||
--
|
||||
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `event_data` TEXT NULL AFTER `og_video`;
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `recipe_data` TEXT NULL AFTER `event_data`;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `custom_schema` TEXT NULL AFTER `canonical_url`;
|
||||
@@ -120,6 +120,7 @@ class BatchController extends BaseController
|
||||
$created++;
|
||||
} catch (\RuntimeException $e) {
|
||||
$skipped++;
|
||||
\Joomla\CMS\Log\Log::add('Batch insert failed for article ' . $article->id . ': ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Total published articles
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__content')->where('state = 1'));
|
||||
$totalArticles = (int) $db->loadResult();
|
||||
|
||||
// Articles with OG tags
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(DISTINCT content_id)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where('published = 1'));
|
||||
$articlesWithOg = (int) $db->loadResult();
|
||||
|
||||
// Articles missing OG data fields
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_title = ''")->where('published = 1'));
|
||||
$missingTitle = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_description = ''")->where('published = 1'));
|
||||
$missingDesc = (int) $db->loadResult();
|
||||
|
||||
$db->setQuery($db->getQuery(true)->select('COUNT(*)')->from('#__mokoog_tags')->where("content_type = 'com_content'")->where("og_image = ''")->where('published = 1'));
|
||||
$missingImage = (int) $db->loadResult();
|
||||
|
||||
$coverage = $totalArticles > 0 ? round(($articlesWithOg / $totalArticles) * 100) : 0;
|
||||
?>
|
||||
<div class="mokoog-coverage card mb-3">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title"><?php echo Text::_('COM_MOKOOG_COVERAGE_TITLE'); ?></h4>
|
||||
<div class="row">
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="display-4 <?php echo $coverage >= 80 ? 'text-success' : ($coverage >= 50 ? 'text-warning' : 'text-danger'); ?>">
|
||||
<?php echo $coverage; ?>%
|
||||
</div>
|
||||
<small class="text-muted"><?php echo Text::_('COM_MOKOOG_COVERAGE_PERCENT'); ?></small>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<ul class="list-unstyled">
|
||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_ARTICLES', $articlesWithOg, $totalArticles); ?></li>
|
||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_TITLE', $missingTitle); ?></li>
|
||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_DESC', $missingDesc); ?></li>
|
||||
<li><?php echo Text::sprintf('COM_MOKOOG_COVERAGE_MISSING_IMAGE', $missingImage); ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,6 +21,7 @@ use Joomla\CMS\Session\Session;
|
||||
|
||||
$token = Session::getFormToken();
|
||||
?>
|
||||
<?php include __DIR__ . '/coverage.php'; ?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokoog&view=tags'); ?>" method="post" name="adminForm" id="adminForm">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_OG_TITLE"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_OG_TITLE_DESC"
|
||||
filter="string"
|
||||
maxlength="70"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="og_description"
|
||||
@@ -25,7 +25,7 @@
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_OG_DESCRIPTION_DESC"
|
||||
filter="string"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
maxlength="512"
|
||||
/>
|
||||
<field
|
||||
name="og_image"
|
||||
@@ -49,6 +49,14 @@
|
||||
<option value="music.song">Music</option>
|
||||
<option value="video.other">Video</option>
|
||||
</field>
|
||||
<field
|
||||
name="og_video"
|
||||
type="url"
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC"
|
||||
filter="url"
|
||||
validate="url"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_seo" label="PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC">
|
||||
@@ -58,7 +66,7 @@
|
||||
label="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE"
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_SEO_TITLE_DESC"
|
||||
filter="string"
|
||||
maxlength="70"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="meta_description"
|
||||
@@ -67,7 +75,7 @@
|
||||
description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC"
|
||||
filter="string"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="robots"
|
||||
@@ -93,5 +101,29 @@
|
||||
validate="url"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_event" label="PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC">
|
||||
<field name="event_start" type="calendar" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_START" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC" format="%Y-%m-%d %H:%M" filter="string" showtime="true" />
|
||||
<field name="event_end" type="calendar" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_END" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC" format="%Y-%m-%d %H:%M" filter="string" showtime="true" />
|
||||
<field name="event_location" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC" filter="string" />
|
||||
<field name="event_address" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC" filter="string" />
|
||||
<field name="event_price" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC" filter="string" />
|
||||
<field name="event_currency" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC" filter="string" default="USD" />
|
||||
<field name="event_url" type="url" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_URL" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC" filter="url" />
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_recipe" label="PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC">
|
||||
<field name="recipe_prep_time" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC" filter="string" hint="PT15M" />
|
||||
<field name="recipe_cook_time" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC" filter="string" hint="PT30M" />
|
||||
<field name="recipe_yield" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC" filter="string" hint="4 servings" />
|
||||
<field name="recipe_calories" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC" filter="string" hint="350" />
|
||||
<field name="recipe_ingredients" type="textarea" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC" filter="string" rows="5" hint="One ingredient per line" />
|
||||
<field name="recipe_category" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC" filter="string" hint="Dessert" />
|
||||
<field name="recipe_cuisine" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC" filter="string" hint="Italian" />
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_custom_schema" label="PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC">
|
||||
<field name="custom_schema" type="textarea" label="PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA" description="PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC" filter="raw" rows="12" class="input-xxlarge" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</form>
|
||||
|
||||
@@ -14,6 +14,9 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recomme
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO="Video URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video to embed in social sharing previews. Supports direct video URLs and YouTube/Vimeo links. Outputs og:video meta tags."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
|
||||
|
||||
@@ -26,3 +29,42 @@ PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this
|
||||
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL="Event Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC="Optional event information for JSON-LD Event schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START="Start Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC="Event start date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END="End Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC="Event end date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION="Venue Name"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC="Name of the event venue or location."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS="Venue Address"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC="Full address of the event venue."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE="Ticket Price"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC="Ticket price (e.g. 50.00). Leave blank if free."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY="Currency"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC="Currency code for ticket price (e.g. USD, EUR, GBP)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL="Ticket URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC="URL where tickets can be purchased."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL="Recipe Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC="Optional recipe information for JSON-LD Recipe schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME="Prep Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC="Preparation time in ISO 8601 duration format (e.g. PT15M for 15 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME="Cook Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC="Cooking time in ISO 8601 duration format (e.g. PT30M for 30 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD="Yield"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC="Number of servings or yield (e.g. 4 servings, 1 loaf)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES="Calories"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC="Calories per serving."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS="Ingredients"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC="One ingredient per line."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL="Custom Schema"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC="Add custom JSON-LD structured data for this page."
|
||||
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA="Custom JSON-LD"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC="Enter valid JSON-LD structured data. The @context will be added automatically if missing. Use for schema types not covered by built-in options (e.g. Course, JobPosting, SoftwareApplication)."
|
||||
|
||||
@@ -14,6 +14,9 @@ PLG_CONTENT_MOKOOG_FIELD_OG_IMAGE_DESC="Custom image for social sharing. Recomme
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE="OG Type"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_TYPE_DESC="The Open Graph content type for this page."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO="Video URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_OG_VIDEO_DESC="URL of a video to embed in social sharing previews. Supports direct video URLs and YouTube/Vimeo links. Outputs og:video meta tags."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_LABEL="SEO Meta Tags"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_SEO_DESC="Control search engine meta tags for this page."
|
||||
|
||||
@@ -26,3 +29,42 @@ PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this
|
||||
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL="Event Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC="Optional event information for JSON-LD Event schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START="Start Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC="Event start date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END="End Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC="Event end date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION="Venue Name"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC="Name of the event venue or location."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS="Venue Address"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC="Full address of the event venue."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE="Ticket Price"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC="Ticket price (e.g. 50.00). Leave blank if free."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY="Currency"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC="Currency code for ticket price (e.g. USD, EUR, GBP)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL="Ticket URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC="URL where tickets can be purchased."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL="Recipe Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC="Optional recipe information for JSON-LD Recipe schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME="Prep Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC="Preparation time in ISO 8601 duration format (e.g. PT15M for 15 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME="Cook Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC="Cooking time in ISO 8601 duration format (e.g. PT30M for 30 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD="Yield"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC="Number of servings or yield (e.g. 4 servings, 1 loaf)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES="Calories"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC="Calories per serving."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS="Ingredients"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC="One ingredient per line."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_LABEL="Custom Schema"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_CUSTOM_SCHEMA_DESC="Add custom JSON-LD structured data for this page."
|
||||
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA="Custom JSON-LD"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CUSTOM_SCHEMA_DESC="Enter valid JSON-LD structured data. The @context will be added automatically if missing. Use for schema types not covered by built-in options (e.g. Course, JobPosting, SoftwareApplication)."
|
||||
|
||||
@@ -102,3 +102,154 @@
|
||||
color: #536471;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* LinkedIn card */
|
||||
.mokoog-card-li {
|
||||
border: 1px solid #e0dfdc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.mokoog-card-li .mokoog-card-body {
|
||||
border-top-color: #e0dfdc;
|
||||
}
|
||||
|
||||
.mokoog-card-li .mokoog-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.mokoog-card-li .mokoog-card-domain {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Discord card */
|
||||
.mokoog-card-dc {
|
||||
background: #2b2d31;
|
||||
border-left: 4px solid #5865f2;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-body {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-img {
|
||||
height: 200px;
|
||||
margin: 0 12px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #00a8fc;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-desc {
|
||||
font-size: 14px;
|
||||
color: #dbdee1;
|
||||
}
|
||||
|
||||
.mokoog-card-dc .mokoog-card-domain {
|
||||
font-size: 12px;
|
||||
color: #b5bac1;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Mastodon card */
|
||||
.mokoog-card-ma {
|
||||
border: 1px solid #c8ccd0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-img {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-body {
|
||||
border-top-color: #c8ccd0;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-desc {
|
||||
font-size: 13px;
|
||||
color: #606984;
|
||||
}
|
||||
|
||||
.mokoog-card-ma .mokoog-card-domain {
|
||||
font-size: 12px;
|
||||
color: #606984;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Slack card */
|
||||
.mokoog-card-sl {
|
||||
border-left: 4px solid #36c5f0;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mokoog-card-sl .mokoog-card-body {
|
||||
border-top: none;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.mokoog-card-sl .mokoog-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1264a3;
|
||||
}
|
||||
|
||||
.mokoog-card-sl .mokoog-card-desc {
|
||||
font-size: 14px;
|
||||
color: #1d1c1d;
|
||||
}
|
||||
|
||||
.mokoog-card-sl .mokoog-card-domain {
|
||||
font-size: 12px;
|
||||
color: #616061;
|
||||
text-transform: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Character count indicators */
|
||||
.mokoog-char-count {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mokoog-char-ok {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.mokoog-char-warn {
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.mokoog-char-over {
|
||||
color: #d32f2f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* SEO scoring panel */
|
||||
.mokoog-seo-score { margin: 15px 0; padding: 15px; background: #f8f9fa; border-radius: 8px; border: 1px solid #dee2e6; }
|
||||
.mokoog-seo-heading { margin: 0 0 10px; font-size: 14px; color: #666; }
|
||||
.mokoog-seo-list { list-style: none; padding: 0; margin: 0 0 10px; }
|
||||
.mokoog-seo-item { padding: 4px 0; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.mokoog-seo-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.mokoog-seo-pass { background: #2e7d32; }
|
||||
.mokoog-seo-fail { background: #d32f2f; }
|
||||
.mokoog-seo-total { font-size: 14px; font-weight: 600; padding-top: 8px; border-top: 1px solid #dee2e6; }
|
||||
.mokoog-seo-total-good { color: #2e7d32; }
|
||||
.mokoog-seo-total-ok { color: #f57c00; }
|
||||
.mokoog-seo-total-bad { color: #d32f2f; }
|
||||
|
||||
@@ -15,9 +15,87 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
ogDesc: document.getElementById('jform_mokoog_og_description'),
|
||||
ogImage: document.getElementById('jform_mokoog_og_image'),
|
||||
articleTitle: document.getElementById('jform_title'),
|
||||
metaDesc: document.getElementById('jform_metadesc')
|
||||
metaDesc: document.getElementById('jform_metadesc'),
|
||||
seoTitle: document.getElementById('jform_mokoog_seo_title'),
|
||||
metaDescription: document.getElementById('jform_mokoog_meta_description')
|
||||
};
|
||||
|
||||
// Character count indicators
|
||||
var charLimits = [
|
||||
{ field: fields.ogTitle, optimal: 60, max: 90 },
|
||||
{ field: fields.ogDesc, optimal: 155, max: 200 },
|
||||
{ field: fields.seoTitle, optimal: 60, max: 70 },
|
||||
{ field: fields.metaDescription, optimal: 155, max: 160 }
|
||||
];
|
||||
|
||||
charLimits.forEach(function (cfg) {
|
||||
if (!cfg.field) return;
|
||||
|
||||
var counter = document.createElement('span');
|
||||
counter.className = 'mokoog-char-count';
|
||||
cfg.field.parentNode.appendChild(counter);
|
||||
|
||||
function refresh() {
|
||||
var len = cfg.field.value.length;
|
||||
counter.textContent = len + '/' + cfg.optimal;
|
||||
|
||||
if (len > cfg.max) {
|
||||
counter.className = 'mokoog-char-count mokoog-char-over';
|
||||
} else if (len > cfg.optimal) {
|
||||
counter.className = 'mokoog-char-count mokoog-char-warn';
|
||||
} else {
|
||||
counter.className = 'mokoog-char-count mokoog-char-ok';
|
||||
}
|
||||
}
|
||||
|
||||
cfg.field.addEventListener('input', refresh);
|
||||
cfg.field.addEventListener('change', refresh);
|
||||
refresh();
|
||||
});
|
||||
|
||||
// AI Generate buttons
|
||||
['ogTitle', 'ogDesc'].forEach(function(fieldKey) {
|
||||
var field = fields[fieldKey];
|
||||
if (!field) return;
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-primary mokoog-ai-btn';
|
||||
btn.textContent = 'Generate with AI';
|
||||
btn.dataset.target = fieldKey;
|
||||
field.parentNode.appendChild(btn);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
var articleTitle = fields.articleTitle ? fields.articleTitle.value : '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating...';
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('task', 'mokoog.aiGenerate');
|
||||
formData.append('field', fieldKey === 'ogTitle' ? 'title' : 'description');
|
||||
formData.append('article_title', articleTitle);
|
||||
formData.append(Joomla.getOptions('csrf.token'), 1);
|
||||
|
||||
fetch(window.location.origin + '/administrator/index.php?option=com_ajax&plugin=mokoog&group=system&format=json', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.data && data.data[0]) {
|
||||
field.value = data.data[0];
|
||||
field.dispatchEvent(new Event('input'));
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Generate with AI';
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Generate with AI';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Find the mokoog fieldset and insert preview after it
|
||||
var fieldset = document.querySelector('[data-showon-id="mokoog"]') ||
|
||||
document.getElementById('attrib-mokoog') ||
|
||||
@@ -110,6 +188,137 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
twCard.appendChild(twBody);
|
||||
wrapper.appendChild(twCard);
|
||||
|
||||
// LinkedIn preview card
|
||||
var liLabel = document.createElement('small');
|
||||
liLabel.className = 'mokoog-platform-label';
|
||||
liLabel.textContent = 'LinkedIn';
|
||||
wrapper.appendChild(liLabel);
|
||||
|
||||
var liCard = document.createElement('div');
|
||||
liCard.className = 'mokoog-card mokoog-card-li';
|
||||
|
||||
var liImg = document.createElement('div');
|
||||
liImg.id = 'mokoog-li-img';
|
||||
liImg.className = 'mokoog-card-img';
|
||||
liCard.appendChild(liImg);
|
||||
|
||||
var liBody = document.createElement('div');
|
||||
liBody.className = 'mokoog-card-body';
|
||||
|
||||
var liTitle = document.createElement('div');
|
||||
liTitle.id = 'mokoog-li-title';
|
||||
liTitle.className = 'mokoog-card-title';
|
||||
liBody.appendChild(liTitle);
|
||||
|
||||
var liDomain = document.createElement('div');
|
||||
liDomain.id = 'mokoog-li-domain';
|
||||
liDomain.className = 'mokoog-card-domain';
|
||||
liBody.appendChild(liDomain);
|
||||
|
||||
liCard.appendChild(liBody);
|
||||
wrapper.appendChild(liCard);
|
||||
|
||||
// Discord preview card
|
||||
var dcLabel = document.createElement('small');
|
||||
dcLabel.className = 'mokoog-platform-label';
|
||||
dcLabel.textContent = 'Discord';
|
||||
wrapper.appendChild(dcLabel);
|
||||
|
||||
var dcCard = document.createElement('div');
|
||||
dcCard.className = 'mokoog-card mokoog-card-dc';
|
||||
|
||||
var dcBody = document.createElement('div');
|
||||
dcBody.className = 'mokoog-card-body';
|
||||
|
||||
var dcTitle = document.createElement('div');
|
||||
dcTitle.id = 'mokoog-dc-title';
|
||||
dcTitle.className = 'mokoog-card-title';
|
||||
dcBody.appendChild(dcTitle);
|
||||
|
||||
var dcDesc = document.createElement('div');
|
||||
dcDesc.id = 'mokoog-dc-desc';
|
||||
dcDesc.className = 'mokoog-card-desc';
|
||||
dcBody.appendChild(dcDesc);
|
||||
|
||||
var dcDomain = document.createElement('div');
|
||||
dcDomain.id = 'mokoog-dc-domain';
|
||||
dcDomain.className = 'mokoog-card-domain';
|
||||
dcBody.appendChild(dcDomain);
|
||||
|
||||
dcCard.appendChild(dcBody);
|
||||
|
||||
var dcImg = document.createElement('div');
|
||||
dcImg.id = 'mokoog-dc-img';
|
||||
dcImg.className = 'mokoog-card-img';
|
||||
dcCard.appendChild(dcImg);
|
||||
|
||||
wrapper.appendChild(dcCard);
|
||||
|
||||
// Mastodon preview card
|
||||
var maLabel = document.createElement('small');
|
||||
maLabel.className = 'mokoog-platform-label';
|
||||
maLabel.textContent = 'Mastodon';
|
||||
wrapper.appendChild(maLabel);
|
||||
|
||||
var maCard = document.createElement('div');
|
||||
maCard.className = 'mokoog-card mokoog-card-ma';
|
||||
|
||||
var maImg = document.createElement('div');
|
||||
maImg.id = 'mokoog-ma-img';
|
||||
maImg.className = 'mokoog-card-img';
|
||||
maCard.appendChild(maImg);
|
||||
|
||||
var maBody = document.createElement('div');
|
||||
maBody.className = 'mokoog-card-body';
|
||||
|
||||
var maTitle = document.createElement('div');
|
||||
maTitle.id = 'mokoog-ma-title';
|
||||
maTitle.className = 'mokoog-card-title';
|
||||
maBody.appendChild(maTitle);
|
||||
|
||||
var maDesc = document.createElement('div');
|
||||
maDesc.id = 'mokoog-ma-desc';
|
||||
maDesc.className = 'mokoog-card-desc';
|
||||
maBody.appendChild(maDesc);
|
||||
|
||||
var maDomain = document.createElement('div');
|
||||
maDomain.id = 'mokoog-ma-domain';
|
||||
maDomain.className = 'mokoog-card-domain';
|
||||
maBody.appendChild(maDomain);
|
||||
|
||||
maCard.appendChild(maBody);
|
||||
wrapper.appendChild(maCard);
|
||||
|
||||
// Slack preview card
|
||||
var slLabel = document.createElement('small');
|
||||
slLabel.className = 'mokoog-platform-label';
|
||||
slLabel.textContent = 'Slack';
|
||||
wrapper.appendChild(slLabel);
|
||||
|
||||
var slCard = document.createElement('div');
|
||||
slCard.className = 'mokoog-card mokoog-card-sl';
|
||||
|
||||
var slBody = document.createElement('div');
|
||||
slBody.className = 'mokoog-card-body';
|
||||
|
||||
var slTitle = document.createElement('div');
|
||||
slTitle.id = 'mokoog-sl-title';
|
||||
slTitle.className = 'mokoog-card-title';
|
||||
slBody.appendChild(slTitle);
|
||||
|
||||
var slDesc = document.createElement('div');
|
||||
slDesc.id = 'mokoog-sl-desc';
|
||||
slDesc.className = 'mokoog-card-desc';
|
||||
slBody.appendChild(slDesc);
|
||||
|
||||
var slDomain = document.createElement('div');
|
||||
slDomain.id = 'mokoog-sl-domain';
|
||||
slDomain.className = 'mokoog-card-domain';
|
||||
slBody.appendChild(slDomain);
|
||||
|
||||
slCard.appendChild(slBody);
|
||||
wrapper.appendChild(slCard);
|
||||
|
||||
preview.appendChild(wrapper);
|
||||
fieldset.parentNode.insertBefore(preview, fieldset.nextSibling);
|
||||
|
||||
@@ -152,19 +361,139 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
} else {
|
||||
twImgEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// LinkedIn (shorter truncation: title 70, no description shown in card)
|
||||
var liTitle = title.length > 70 ? title.substring(0, 67) + '...' : title;
|
||||
document.getElementById('mokoog-li-title').textContent = liTitle;
|
||||
document.getElementById('mokoog-li-domain').textContent = domain;
|
||||
var liImgEl = document.getElementById('mokoog-li-img');
|
||||
if (img) {
|
||||
liImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
|
||||
liImgEl.style.display = '';
|
||||
} else {
|
||||
liImgEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Discord (title 256, desc 350)
|
||||
var dcTitle = title.length > 256 ? title.substring(0, 253) + '...' : title;
|
||||
var dcDesc = desc.length > 350 ? desc.substring(0, 347) + '...' : desc;
|
||||
document.getElementById('mokoog-dc-title').textContent = dcTitle;
|
||||
document.getElementById('mokoog-dc-desc').textContent = dcDesc;
|
||||
document.getElementById('mokoog-dc-domain').textContent = domain;
|
||||
var dcImgEl = document.getElementById('mokoog-dc-img');
|
||||
if (img) {
|
||||
dcImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
|
||||
dcImgEl.style.display = '';
|
||||
} else {
|
||||
dcImgEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Mastodon (title 70, desc 200)
|
||||
var maTitle = title.length > 70 ? title.substring(0, 67) + '...' : title;
|
||||
var maDesc = desc.length > 200 ? desc.substring(0, 197) + '...' : desc;
|
||||
document.getElementById('mokoog-ma-title').textContent = maTitle;
|
||||
document.getElementById('mokoog-ma-desc').textContent = maDesc;
|
||||
document.getElementById('mokoog-ma-domain').textContent = domain;
|
||||
var maImgEl = document.getElementById('mokoog-ma-img');
|
||||
if (img) {
|
||||
maImgEl.style.backgroundImage = 'url(' + encodeURI(img) + ')';
|
||||
maImgEl.style.display = '';
|
||||
} else {
|
||||
maImgEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Slack (title 70, desc 150, no image)
|
||||
var slTitle = title.length > 70 ? title.substring(0, 67) + '...' : title;
|
||||
var slDesc = desc.length > 150 ? desc.substring(0, 147) + '...' : desc;
|
||||
document.getElementById('mokoog-sl-title').textContent = slTitle;
|
||||
document.getElementById('mokoog-sl-desc').textContent = slDesc;
|
||||
document.getElementById('mokoog-sl-domain').textContent = domain;
|
||||
}
|
||||
|
||||
// SEO scoring panel
|
||||
var seoChecks = [
|
||||
{ id: 'og-title', label: 'OG Title', check: function() { return fields.ogTitle && fields.ogTitle.value.length > 0; }},
|
||||
{ id: 'og-desc', label: 'OG Description', check: function() { return fields.ogDesc && fields.ogDesc.value.length > 0; }},
|
||||
{ id: 'og-image', label: 'OG Image', check: function() { return fields.ogImage && fields.ogImage.value.length > 0; }},
|
||||
{ id: 'seo-title', label: 'SEO Title', check: function() { return fields.seoTitle && fields.seoTitle.value.length > 0; }},
|
||||
{ id: 'meta-desc', label: 'Meta Description', check: function() { return fields.metaDescription && fields.metaDescription.value.length > 0; }},
|
||||
{ id: 'title-length', label: 'Title Length (\u226460)', check: function() {
|
||||
var t = (fields.ogTitle && fields.ogTitle.value) || (fields.articleTitle && fields.articleTitle.value) || '';
|
||||
return t.length > 0 && t.length <= 60;
|
||||
}},
|
||||
{ id: 'desc-length', label: 'Description Length (\u2264160)', check: function() {
|
||||
var d = (fields.ogDesc && fields.ogDesc.value) || (fields.metaDesc && fields.metaDesc.value) || '';
|
||||
return d.length > 0 && d.length <= 160;
|
||||
}}
|
||||
];
|
||||
|
||||
var seoPanel = document.createElement('div');
|
||||
seoPanel.className = 'mokoog-seo-score';
|
||||
|
||||
var seoHeading = document.createElement('h4');
|
||||
seoHeading.className = 'mokoog-seo-heading';
|
||||
seoHeading.textContent = 'SEO Analysis';
|
||||
seoPanel.appendChild(seoHeading);
|
||||
|
||||
var seoList = document.createElement('ul');
|
||||
seoList.className = 'mokoog-seo-list';
|
||||
|
||||
var seoDots = {};
|
||||
seoChecks.forEach(function (chk) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'mokoog-seo-item';
|
||||
|
||||
var dot = document.createElement('span');
|
||||
dot.className = 'mokoog-seo-dot mokoog-seo-fail';
|
||||
seoDots[chk.id] = dot;
|
||||
li.appendChild(dot);
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.textContent = chk.label;
|
||||
li.appendChild(label);
|
||||
|
||||
seoList.appendChild(li);
|
||||
});
|
||||
|
||||
seoPanel.appendChild(seoList);
|
||||
|
||||
var seoTotal = document.createElement('div');
|
||||
seoTotal.className = 'mokoog-seo-total';
|
||||
seoPanel.appendChild(seoTotal);
|
||||
|
||||
wrapper.parentNode.insertBefore(seoPanel, wrapper.nextSibling);
|
||||
|
||||
function updateSeoScore() {
|
||||
var passed = 0;
|
||||
seoChecks.forEach(function (chk) {
|
||||
var ok = chk.check();
|
||||
if (ok) passed++;
|
||||
seoDots[chk.id].className = 'mokoog-seo-dot ' + (ok ? 'mokoog-seo-pass' : 'mokoog-seo-fail');
|
||||
});
|
||||
|
||||
seoTotal.textContent = passed + '/' + seoChecks.length + ' checks passed';
|
||||
|
||||
if (passed === seoChecks.length) {
|
||||
seoTotal.className = 'mokoog-seo-total mokoog-seo-total-good';
|
||||
} else if (passed >= Math.ceil(seoChecks.length / 2)) {
|
||||
seoTotal.className = 'mokoog-seo-total mokoog-seo-total-ok';
|
||||
} else {
|
||||
seoTotal.className = 'mokoog-seo-total mokoog-seo-total-bad';
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(fields).forEach(function (el) {
|
||||
if (el) {
|
||||
el.addEventListener('input', updatePreview);
|
||||
el.addEventListener('change', updatePreview);
|
||||
el.addEventListener('input', function () { updatePreview(); updateSeoScore(); });
|
||||
el.addEventListener('change', function () { updatePreview(); updateSeoScore(); });
|
||||
}
|
||||
});
|
||||
|
||||
if (fields.ogImage) {
|
||||
var observer = new MutationObserver(updatePreview);
|
||||
var observer = new MutationObserver(function () { updatePreview(); updateSeoScore(); });
|
||||
observer.observe(fields.ogImage, { attributes: true, attributeFilter: ['value'] });
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
updateSeoScore();
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoJoomOpenGraph</name>
|
||||
<version>01.03.01</version>
|
||||
<version>01.04.08</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -98,7 +98,24 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
$ogData = $this->loadOgData($contentType, $id, $language);
|
||||
|
||||
if ($ogData) {
|
||||
$form->bind(['mokoog' => (array) $ogData]);
|
||||
$bindData = (array) $ogData;
|
||||
|
||||
// Unpack JSON blob fields into individual form fields
|
||||
foreach (['event_data', 'recipe_data'] as $jsonField) {
|
||||
if (!empty($bindData[$jsonField])) {
|
||||
$decoded = json_decode($bindData[$jsonField], true);
|
||||
|
||||
if (\is_array($decoded)) {
|
||||
foreach ($decoded as $key => $value) {
|
||||
$bindData[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unset($bindData[$jsonField]);
|
||||
}
|
||||
|
||||
$form->bind(['mokoog' => $bindData]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,7 +211,8 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName([
|
||||
'og_title', 'og_description', 'og_image', 'og_type',
|
||||
'og_title', 'og_description', 'og_image', 'og_type', 'og_video',
|
||||
'event_data', 'recipe_data', 'custom_schema',
|
||||
'seo_title', 'meta_description', 'robots', 'canonical_url',
|
||||
]))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
@@ -245,12 +263,16 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
'content_type' => $contentType,
|
||||
'content_id' => $contentId,
|
||||
'language' => $language,
|
||||
'og_title' => trim($ogData['og_title'] ?? ''),
|
||||
'og_description' => trim($ogData['og_description'] ?? ''),
|
||||
'og_title' => strip_tags(trim($ogData['og_title'] ?? '')),
|
||||
'og_description' => strip_tags(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'] ?? ''),
|
||||
'og_video' => $this->sanitizeUrl($ogData['og_video'] ?? ''),
|
||||
'event_data' => $this->packJsonFields($ogData, ['event_start', 'event_end', 'event_location', 'event_address', 'event_price', 'event_currency', 'event_url']),
|
||||
'recipe_data' => $this->packJsonFields($ogData, ['recipe_prep_time', 'recipe_cook_time', 'recipe_yield', 'recipe_calories', 'recipe_ingredients', 'recipe_category', 'recipe_cuisine']),
|
||||
'custom_schema' => $this->validateJson($ogData['custom_schema'] ?? ''),
|
||||
'seo_title' => strip_tags(trim($ogData['seo_title'] ?? '')),
|
||||
'meta_description' => strip_tags(trim($ogData['meta_description'] ?? '')),
|
||||
'robots' => trim($robots),
|
||||
'canonical_url' => trim($ogData['canonical_url'] ?? ''),
|
||||
'published' => 1,
|
||||
@@ -266,6 +288,69 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack form fields into a JSON string for storage.
|
||||
*
|
||||
* @param array $ogData Form data array
|
||||
* @param array $fields Field names to pack
|
||||
*
|
||||
* @return string JSON string or empty
|
||||
*/
|
||||
private function packJsonFields(array $ogData, array $fields): string
|
||||
{
|
||||
$data = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$val = trim($ogData[$field] ?? '');
|
||||
|
||||
if ($val !== '') {
|
||||
$data[$field] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
return !empty($data) ? json_encode($data) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a JSON string — returns trimmed JSON or empty string if invalid.
|
||||
*
|
||||
* @param string $json Raw JSON input
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function validateJson(string $json): string
|
||||
{
|
||||
$json = trim($json);
|
||||
|
||||
if ($json === '' || json_decode($json) === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a URL to only allow http/https schemes.
|
||||
*
|
||||
* @param string $url Raw URL value
|
||||
*
|
||||
* @return string Sanitized URL or empty string
|
||||
*/
|
||||
private function sanitizeUrl(string $url): string
|
||||
{
|
||||
$url = trim($url);
|
||||
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the language tag from content data.
|
||||
*
|
||||
|
||||
@@ -23,6 +23,8 @@ PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR="Fediverse Creator"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR_DESC="Your Fediverse/Mastodon handle (e.g. @user@mastodon.social). Outputs a fediverse:creator meta tag for author attribution on Mastodon and other Fediverse platforms."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
|
||||
@@ -35,5 +37,59 @@ PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ="JSON-LD FAQ Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC="Auto-detect FAQ sections from article headings (h3/h4 + paragraphs) and output FAQPage structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO="JSON-LD HowTo Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC="Auto-detect step-by-step instructions from ordered lists (ol/li) and output HowTo structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_LOCALBUSINESS="Local Business"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED="Enable LocalBusiness Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED_DESC="Output LocalBusiness JSON-LD structured data on all pages."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME="Business Name"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME_DESC="Your business name for structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE="Business Type"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE_DESC="Schema.org business type."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET="Street Address"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET_DESC="Street address of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY="City"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY_DESC="City of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION="State/Region"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION_DESC="State or region of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL="Postal Code"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL_DESC="Postal/ZIP code of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY="Country"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY_DESC="Country code (e.g. US, GB, DE)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE="Phone"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE_DESC="Business phone number."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL="Email"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL_DESC="Business email address."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_URL="Website URL"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_URL_DESC="Business website URL."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS="Opening Hours"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS_DESC="Opening hours in schema.org format (e.g. Mo-Fr 09:00-17:00)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE="Latitude"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE_DESC="Geographic latitude of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE="Longitude"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC="Geographic longitude of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE="Price Range"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC="Price range indicator (e.g. $, $$, $$$)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE="Per-platform Image Sizes"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC="Generate platform-specific image sizes (Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400) in addition to the default Facebook 1200x630."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP="XML Sitemap"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED="Enable Sitemap"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC="Auto-generate sitemap.xml when articles are saved. Respects noindex robots directives."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ="Change Frequency"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC="Default change frequency for sitemap entries."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_AI="AI Meta Generation"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED="Enable AI Generation"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC="Show Generate with AI buttons next to OG title and description fields."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER="AI Provider"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC="Select the AI API provider."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY="API Key"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC="Your AI provider API key."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL="Model"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC="AI model to use for generation."
|
||||
|
||||
@@ -23,6 +23,8 @@ PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID="Facebook App ID"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FB_APP_ID_DESC="Your Facebook App ID for fb:app_id meta tag."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR="Discord Embed Color"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC="The color of the embed sidebar when shared on Discord. Sets the theme-color meta tag. Leave blank to use Discord defaults."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR="Fediverse Creator"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR_DESC="Your Fediverse/Mastodon handle (e.g. @user@mastodon.social). Outputs a fediverse:creator meta tag for author attribution on Mastodon and other Fediverse platforms."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE="Auto-generate Tags"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_GENERATE_DESC="Automatically generate OG tags from article content when no custom tags are set."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_STRIP_HTML="Strip HTML from Description"
|
||||
@@ -35,5 +37,59 @@ PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ="JSON-LD FAQ Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC="Auto-detect FAQ sections from article headings (h3/h4 + paragraphs) and output FAQPage structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO="JSON-LD HowTo Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC="Auto-detect step-by-step instructions from ordered lists (ol/li) and output HowTo structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_LOCALBUSINESS="Local Business"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED="Enable LocalBusiness Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED_DESC="Output LocalBusiness JSON-LD structured data on all pages."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME="Business Name"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_NAME_DESC="Your business name for structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE="Business Type"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE_DESC="Schema.org business type."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET="Street Address"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_STREET_DESC="Street address of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY="City"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_CITY_DESC="City of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION="State/Region"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_REGION_DESC="State or region of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL="Postal Code"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL_DESC="Postal/ZIP code of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY="Country"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY_DESC="Country code (e.g. US, GB, DE)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE="Phone"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE_DESC="Business phone number."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL="Email"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL_DESC="Business email address."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_URL="Website URL"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_URL_DESC="Business website URL."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS="Opening Hours"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS_DESC="Opening hours in schema.org format (e.g. Mo-Fr 09:00-17:00)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE="Latitude"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE_DESC="Geographic latitude of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE="Longitude"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC="Geographic longitude of your business."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE="Price Range"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC="Price range indicator (e.g. $, $$, $$$)."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE="Per-platform Image Sizes"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC="Generate platform-specific image sizes (Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400) in addition to the default Facebook 1200x630."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP="XML Sitemap"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED="Enable Sitemap"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC="Auto-generate sitemap.xml when articles are saved. Respects noindex robots directives."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ="Change Frequency"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC="Default change frequency for sitemap entries."
|
||||
|
||||
PLG_SYSTEM_MOKOOG_FIELDSET_AI="AI Meta Generation"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED="Enable AI Generation"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC="Show Generate with AI buttons next to OG title and description fields."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER="AI Provider"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC="Select the AI API provider."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY="API Key"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC="Your AI provider API key."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL="Model"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC="AI model to use for generation."
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoJoomOpenGraph</name>
|
||||
<version>01.03.01</version>
|
||||
<version>01.04.08</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -106,6 +106,14 @@
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_DISCORD_COLOR_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="fediverse_creator"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_FEDIVERSE_CREATOR_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="advanced" label="PLG_SYSTEM_MOKOOG_FIELDSET_ADVANCED">
|
||||
<field
|
||||
@@ -150,6 +158,17 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="platform_resize"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_PLATFORM_RESIZE_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_enabled"
|
||||
type="radio"
|
||||
@@ -161,6 +180,28 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_faq"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_howto"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_breadcrumbs"
|
||||
type="radio"
|
||||
@@ -173,6 +214,158 @@
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="localbusiness" label="PLG_SYSTEM_MOKOOG_FIELDSET_LOCALBUSINESS">
|
||||
<field
|
||||
name="lb_enabled"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_ENABLED_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="lb_name"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_NAME"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_NAME_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_type"
|
||||
type="list"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_TYPE_DESC"
|
||||
default="LocalBusiness"
|
||||
>
|
||||
<option value="LocalBusiness">LocalBusiness</option>
|
||||
<option value="Restaurant">Restaurant</option>
|
||||
<option value="Store">Store</option>
|
||||
<option value="MedicalBusiness">MedicalBusiness</option>
|
||||
<option value="LegalService">LegalService</option>
|
||||
<option value="FinancialService">FinancialService</option>
|
||||
<option value="EducationalOrganization">EducationalOrganization</option>
|
||||
</field>
|
||||
<field
|
||||
name="lb_street"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_STREET"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_STREET_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_city"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_CITY"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_CITY_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_region"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_REGION"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_REGION_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_postal"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_POSTAL_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_country"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_COUNTRY_DESC"
|
||||
default="US"
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_phone"
|
||||
type="tel"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_PHONE_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="lb_email"
|
||||
type="email"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_EMAIL_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="lb_url"
|
||||
type="url"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_URL"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_URL_DESC"
|
||||
default=""
|
||||
/>
|
||||
<field
|
||||
name="lb_opening_hours"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_OPENING_HOURS_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_latitude"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_LATITUDE_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_longitude"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_LONGITUDE_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="lb_price_range"
|
||||
type="text"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_LB_PRICE_RANGE_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="sitemap" label="PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP">
|
||||
<field name="sitemap_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC" default="0" class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="sitemap_changefreq" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC" default="weekly">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="ai" label="PLG_SYSTEM_MOKOOG_FIELDSET_AI">
|
||||
<field name="ai_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC" default="0" class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="ai_provider" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER" description="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC" default="claude">
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
</field>
|
||||
<field name="ai_api_key" type="password" label="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY" description="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC" filter="string" />
|
||||
<field name="ai_model" type="text" label="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL" description="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC" default="claude-haiku-4-5-20251001" filter="string" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
|
||||
@@ -19,6 +19,7 @@ use Joomla\Event\Event;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Joomla\Plugin\System\MokoOG\Helper\ImageHelper;
|
||||
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
|
||||
use Joomla\Plugin\System\MokoOG\Helper\SitemapBuilder;
|
||||
|
||||
final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
@@ -37,6 +38,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
return [
|
||||
'onAfterRoute' => 'onAfterRoute',
|
||||
'onBeforeCompileHead' => 'onBeforeCompileHead',
|
||||
'onContentAfterSave' => 'onContentAfterSaveRebuildSitemap',
|
||||
'onAjaxMokoog' => 'onAjaxMokoog',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -92,7 +95,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
|
||||
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) {
|
||||
foreach (['og_title', 'og_description', 'og_image', 'og_type', 'og_video', 'seo_title', 'meta_description', 'robots', 'canonical_url'] as $field) {
|
||||
if (empty($ogData->$field) && !empty($catOg->$field)) {
|
||||
$ogData->$field = $catOg->$field;
|
||||
}
|
||||
@@ -156,7 +159,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
$doc->setMetaData('twitter:description', $description);
|
||||
|
||||
if ($image) {
|
||||
$doc->setMetaData('twitter:image', $this->resolveImageUrl($image));
|
||||
$twitterImage = ($this->params->get('auto_resize', 1) && $this->params->get('platform_resize', 0))
|
||||
? ImageHelper::resizeForPlatform($image, 'twitter')
|
||||
: $image;
|
||||
$doc->setMetaData('twitter:image', $this->resolveImageUrl($twitterImage));
|
||||
}
|
||||
|
||||
if ($twitterSite) {
|
||||
@@ -177,6 +183,38 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
$doc->setMetaData('theme-color', $discordColor);
|
||||
}
|
||||
|
||||
// Fediverse/Mastodon creator attribution
|
||||
$fediverseCreator = $this->params->get('fediverse_creator', '');
|
||||
|
||||
if ($fediverseCreator) {
|
||||
$doc->setMetaData('fediverse:creator', $fediverseCreator);
|
||||
}
|
||||
|
||||
// og:video tags
|
||||
$videoUrl = $ogData->og_video ?? '';
|
||||
|
||||
if ($videoUrl) {
|
||||
$doc->setMetaData('og:video', $videoUrl, 'property');
|
||||
|
||||
if (str_starts_with($videoUrl, 'https://')) {
|
||||
$doc->setMetaData('og:video:secure_url', $videoUrl, 'property');
|
||||
}
|
||||
|
||||
// Detect video type from URL — embeds vs direct files
|
||||
$isEmbed = str_contains($videoUrl, 'youtube.com') || str_contains($videoUrl, 'youtu.be')
|
||||
|| str_contains($videoUrl, 'vimeo.com');
|
||||
|
||||
if ($isEmbed) {
|
||||
$doc->setMetaData('og:video:type', 'text/html', 'property');
|
||||
} else {
|
||||
$ext = strtolower(pathinfo(parse_url($videoUrl, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION));
|
||||
$mimeMap = ['mp4' => 'video/mp4', 'webm' => 'video/webm', 'ogg' => 'video/ogg'];
|
||||
$doc->setMetaData('og:video:type', $mimeMap[$ext] ?? 'video/mp4', 'property');
|
||||
$doc->setMetaData('og:video:width', '1280', 'property');
|
||||
$doc->setMetaData('og:video:height', '720', 'property');
|
||||
}
|
||||
}
|
||||
|
||||
// LinkedIn article tags
|
||||
if ($option === 'com_content' && $view === 'article' && $id > 0) {
|
||||
$doc->setMetaData('article:published_time', $this->getArticleDate($id, 'publish_up'), 'property');
|
||||
@@ -189,13 +227,34 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
// MokoSuiteShop product meta tags
|
||||
// MokoSuiteShop product meta tags (pricing + Pinterest availability)
|
||||
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
|
||||
$productData = $this->loadShopProduct($id);
|
||||
|
||||
if ($productData) {
|
||||
$doc->setMetaData('product:price:amount', number_format((float) $productData->price, 2, '.', ''), 'property');
|
||||
$doc->setMetaData('product:price:currency', $productData->currency ?: 'USD', 'property');
|
||||
$availability = ((int) ($productData->stock_qty ?? 0) > 0) ? 'instock' : 'outofstock';
|
||||
$doc->setMetaData('product:availability', $availability, 'property');
|
||||
}
|
||||
}
|
||||
|
||||
// Pinterest article:tag rich pins (from Joomla content tags)
|
||||
if ($option === 'com_content' && $view === 'article' && $id > 0) {
|
||||
$db = Factory::getDbo();
|
||||
$tagQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('t.title'))
|
||||
->from($db->quoteName('#__tags', 't'))
|
||||
->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm')
|
||||
. ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id'))
|
||||
->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article'))
|
||||
->where($db->quoteName('m.content_item_id') . ' = ' . $id)
|
||||
->where($db->quoteName('t.published') . ' = 1');
|
||||
$db->setQuery($tagQuery);
|
||||
$tags = $db->loadColumn();
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$doc->setMetaData('article:tag', $tag, 'property');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +288,85 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($schema));
|
||||
}
|
||||
|
||||
if (!empty($ogData->og_video)) {
|
||||
$videoSchema = JsonLdBuilder::buildVideo($ogData->og_video, $title, $description, $imageUrl);
|
||||
|
||||
if ($videoSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($videoSchema));
|
||||
}
|
||||
}
|
||||
|
||||
// FAQ schema (auto-detected from article headings)
|
||||
if ($this->params->get('jsonld_faq', 1) && $option === 'com_content' && $view === 'article' && $id > 0) {
|
||||
$faqItems = $this->extractFaqFromContent($id);
|
||||
|
||||
if (!empty($faqItems)) {
|
||||
$faqSchema = JsonLdBuilder::buildFaq($faqItems);
|
||||
|
||||
if ($faqSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($faqSchema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HowTo schema (auto-detected from ordered lists)
|
||||
if ($this->params->get('jsonld_howto', 1) && $option === 'com_content' && $view === 'article' && $id > 0) {
|
||||
$howToSteps = $this->extractHowToFromContent($id);
|
||||
|
||||
if (!empty($howToSteps)) {
|
||||
$howToSchema = JsonLdBuilder::buildHowTo($title, $howToSteps, $imageUrl);
|
||||
|
||||
if ($howToSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($howToSchema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event JSON-LD from per-article event data
|
||||
$eventJson = $ogData->event_data ?? '';
|
||||
|
||||
if (!empty($eventJson)) {
|
||||
$eventObj = json_decode($eventJson);
|
||||
|
||||
if ($eventObj && !empty($eventObj->event_start)) {
|
||||
$eventSchema = JsonLdBuilder::buildEvent($title, $description, $imageUrl, $eventObj);
|
||||
|
||||
if ($eventSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($eventSchema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recipe JSON-LD from per-article recipe data
|
||||
$recipeJson = $ogData->recipe_data ?? '';
|
||||
|
||||
if (!empty($recipeJson)) {
|
||||
$recipeObj = json_decode($recipeJson);
|
||||
|
||||
if ($recipeObj) {
|
||||
$recipeSchema = JsonLdBuilder::buildRecipe($title, $description, $imageUrl, $recipeObj);
|
||||
|
||||
if ($recipeSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($recipeSchema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom JSON-LD schema (user-provided)
|
||||
$customSchema = $ogData->custom_schema ?? '';
|
||||
|
||||
if (!empty($customSchema)) {
|
||||
$decoded = json_decode($customSchema, true);
|
||||
|
||||
if ($decoded) {
|
||||
if (empty($decoded['@context'])) {
|
||||
$decoded['@context'] = 'https://schema.org';
|
||||
}
|
||||
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($decoded));
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->params->get('jsonld_breadcrumbs', 1)) {
|
||||
$breadcrumbs = JsonLdBuilder::buildBreadcrumbs();
|
||||
|
||||
@@ -237,6 +375,15 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LocalBusiness JSON-LD
|
||||
if ($this->params->get('lb_enabled', 0)) {
|
||||
$lbSchema = JsonLdBuilder::buildLocalBusiness($this->params);
|
||||
|
||||
if ($lbSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($lbSchema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,6 +446,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
'og_description' => '',
|
||||
'og_image' => '',
|
||||
'og_type' => '',
|
||||
'og_video' => '',
|
||||
'event_data' => '',
|
||||
'recipe_data' => '',
|
||||
'custom_schema' => '',
|
||||
'seo_title' => '',
|
||||
'meta_description' => '',
|
||||
'robots' => '',
|
||||
@@ -569,6 +720,220 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
return $article->author_name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract FAQ question/answer pairs from article content.
|
||||
*
|
||||
* @param int $articleId Article ID
|
||||
*
|
||||
* @return array Array of ['question' => '...', 'answer' => '...'] pairs
|
||||
*/
|
||||
private function extractFaqFromContent(int $articleId): array
|
||||
{
|
||||
$article = $this->loadArticle($articleId);
|
||||
|
||||
if (!$article) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = ($article->introtext ?? '') . ($article->fulltext ?? '');
|
||||
|
||||
if (trim($content) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$faqItems = [];
|
||||
|
||||
if (preg_match_all('/<h[34][^>]*>(.*?)<\/h[34]>\s*((?:<p[^>]*>.*?<\/p>\s*)+)/si', $content, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$question = trim(strip_tags($match[1]));
|
||||
$answer = trim(strip_tags($match[2]));
|
||||
|
||||
if ($question !== '' && $answer !== '') {
|
||||
$faqItems[] = [
|
||||
'question' => $question,
|
||||
'answer' => $answer,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $faqItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract HowTo steps from ordered lists in article content.
|
||||
*
|
||||
* @param int $articleId Article ID
|
||||
*
|
||||
* @return array Array of ['name' => '...', 'text' => '...'] pairs
|
||||
*/
|
||||
private function extractHowToFromContent(int $articleId): array
|
||||
{
|
||||
$article = $this->loadArticle($articleId);
|
||||
|
||||
if (!$article) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = ($article->introtext ?? '') . ($article->fulltext ?? '');
|
||||
|
||||
if (!preg_match('/<ol[^>]*>(.*?)<\/ol>/si', $content, $olMatch)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!preg_match_all('/<li[^>]*>(.*?)<\/li>/si', $olMatch[1], $liMatches)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$steps = [];
|
||||
|
||||
foreach ($liMatches[1] as $liHtml) {
|
||||
$text = trim(strip_tags($liHtml));
|
||||
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $text;
|
||||
|
||||
if (preg_match('/<(?:b|strong)[^>]*>(.*?)<\/(?:b|strong)>/si', $liHtml, $boldMatch)) {
|
||||
$name = trim(strip_tags($boldMatch[1]));
|
||||
} elseif (preg_match('/^([^.!?]+[.!?])/', $text, $sentenceMatch)) {
|
||||
$name = trim($sentenceMatch[1]);
|
||||
}
|
||||
|
||||
$steps[] = [
|
||||
'name' => $name,
|
||||
'text' => $text,
|
||||
];
|
||||
}
|
||||
|
||||
return $steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild sitemap.xml when article content is saved.
|
||||
*
|
||||
* @param Event $event The event
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function onContentAfterSaveRebuildSitemap(Event $event): void
|
||||
{
|
||||
if (!$this->params->get('sitemap_enabled', 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$context] = array_values($event->getArguments());
|
||||
|
||||
if ($context !== 'com_content.article') {
|
||||
return;
|
||||
}
|
||||
|
||||
$changefreq = $this->params->get('sitemap_changefreq', 'weekly');
|
||||
$xml = SitemapBuilder::generate($changefreq);
|
||||
SitemapBuilder::writeToFile($xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX requests for AI meta tag generation.
|
||||
*
|
||||
* @param Event $event The event
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function onAjaxMokoog(Event $event): void
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
if (!$app->isClient('administrator')) {
|
||||
return;
|
||||
}
|
||||
|
||||
\Joomla\CMS\Session\Session::checkToken() or die('Invalid Token');
|
||||
|
||||
if (!$this->params->get('ai_enabled', 0)) {
|
||||
$event->setArgument('result', ['AI generation is not enabled']);
|
||||
return;
|
||||
}
|
||||
|
||||
$apiKey = $this->params->get('ai_api_key', '');
|
||||
$provider = $this->params->get('ai_provider', 'claude');
|
||||
$model = $this->params->get('ai_model', 'claude-haiku-4-5-20251001');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
$event->setArgument('result', ['API key not configured']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = $app->getInput();
|
||||
$field = $input->getString('field', 'title');
|
||||
$articleTitle = $input->getString('article_title', '');
|
||||
|
||||
$prompt = $field === 'title'
|
||||
? "Generate a concise, engaging social media sharing title (max 60 characters) for an article titled: \"$articleTitle\". Return only the title text, no quotes or explanation."
|
||||
: "Generate a compelling social media sharing description (max 155 characters) for an article titled: \"$articleTitle\". Return only the description text, no quotes or explanation.";
|
||||
|
||||
try {
|
||||
$result = $this->callAiApi($provider, $apiKey, $model, $prompt);
|
||||
$event->setArgument('result', [$result]);
|
||||
} catch (\Exception $e) {
|
||||
$event->setArgument('result', ['Error: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an AI API (Claude or OpenAI) with a prompt.
|
||||
*
|
||||
* @param string $provider Provider name (claude or openai)
|
||||
* @param string $apiKey API key
|
||||
* @param string $model Model name
|
||||
* @param string $prompt Prompt text
|
||||
*
|
||||
* @return string Generated text
|
||||
*/
|
||||
private function callAiApi(string $provider, string $apiKey, string $model, string $prompt): string
|
||||
{
|
||||
$http = \Joomla\CMS\Http\HttpFactory::getHttp();
|
||||
|
||||
if ($provider === 'claude') {
|
||||
$response = $http->post(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
json_encode([
|
||||
'model' => $model,
|
||||
'max_tokens' => 200,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
]),
|
||||
[
|
||||
'Content-Type' => 'application/json',
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
]
|
||||
);
|
||||
|
||||
$data = json_decode($response->body, true);
|
||||
|
||||
return trim($data['content'][0]['text'] ?? '');
|
||||
}
|
||||
|
||||
$response = $http->post(
|
||||
'https://api.openai.com/v1/chat/completions',
|
||||
json_encode([
|
||||
'model' => $model,
|
||||
'max_tokens' => 200,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
]),
|
||||
[
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $apiKey,
|
||||
]
|
||||
);
|
||||
|
||||
$data = json_decode($response->body, true);
|
||||
|
||||
return trim($data['choices'][0]['message']['content'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn administrators once per session when no license key is configured.
|
||||
*
|
||||
|
||||
@@ -149,6 +149,137 @@ class ImageHelper
|
||||
return $outputRel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image for a specific platform.
|
||||
*
|
||||
* @param string $imagePath Relative image path
|
||||
* @param string $platform Platform name (facebook, twitter, pinterest, whatsapp)
|
||||
*
|
||||
* @return string Path to the resized image
|
||||
*/
|
||||
public static function resizeForPlatform(string $imagePath, string $platform): string
|
||||
{
|
||||
$sizes = [
|
||||
'facebook' => ['width' => 1200, 'height' => 630],
|
||||
'twitter' => ['width' => 1200, 'height' => 600],
|
||||
'pinterest' => ['width' => 1000, 'height' => 1500],
|
||||
'whatsapp' => ['width' => 400, 'height' => 400],
|
||||
];
|
||||
|
||||
if (!isset($sizes[$platform])) {
|
||||
return self::resize($imagePath);
|
||||
}
|
||||
|
||||
$size = $sizes[$platform];
|
||||
|
||||
return self::resizeToSize($imagePath, $size['width'], $size['height'], $platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image to specific dimensions with a platform-specific subdirectory.
|
||||
*
|
||||
* @param string $imagePath Image path relative to JPATH_ROOT
|
||||
* @param int $width Target width
|
||||
* @param int $height Target height
|
||||
* @param string $subdir Subdirectory name for output (e.g. platform name)
|
||||
*
|
||||
* @return string Path to the output image (relative to JPATH_ROOT)
|
||||
*/
|
||||
private static function resizeToSize(string $imagePath, int $width, int $height, string $subdir = ''): 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 <= $width && $origHeight <= $height) {
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
// Build output directory with optional subdirectory
|
||||
$outputRelDir = self::OUTPUT_DIR . ($subdir ? '/' . $subdir : '');
|
||||
$outputDir = JPATH_ROOT . '/' . $outputRelDir;
|
||||
|
||||
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
|
||||
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . $outputRelDir, Log::WARNING, 'mokoog');
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
// Generate output filename based on source hash + dimensions
|
||||
$hash = md5($imagePath . $width . $height);
|
||||
$outputName = $hash . '.jpg';
|
||||
$outputPath = $outputDir . '/' . $outputName;
|
||||
$outputRel = $outputRelDir . '/' . $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 = $width / $height;
|
||||
$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($width, $height);
|
||||
|
||||
imagecopyresampled(
|
||||
$output,
|
||||
$source,
|
||||
0,
|
||||
0,
|
||||
$cropX,
|
||||
$cropY,
|
||||
$width,
|
||||
$height,
|
||||
$cropWidth,
|
||||
$cropHeight
|
||||
);
|
||||
|
||||
// Save as JPEG
|
||||
imagejpeg($output, $outputPath, self::JPEG_QUALITY);
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($output);
|
||||
|
||||
return $outputRel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a generated image file.
|
||||
*
|
||||
|
||||
@@ -248,6 +248,413 @@ class JsonLdBuilder
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build VideoObject schema for pages with a video URL.
|
||||
*
|
||||
* @param string $videoUrl Video URL (e.g. YouTube, Vimeo, or direct)
|
||||
* @param string $title Video title
|
||||
* @param string $description Video description
|
||||
* @param string $imageUrl Thumbnail image URL (absolute)
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildVideo(string $videoUrl, string $title, string $description, string $imageUrl): ?array
|
||||
{
|
||||
if (empty($videoUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'VideoObject',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'thumbnailUrl' => $imageUrl,
|
||||
'contentUrl' => $videoUrl,
|
||||
'uploadDate' => Factory::getDate()->toISO8601(),
|
||||
];
|
||||
|
||||
// Add embedUrl for YouTube and Vimeo
|
||||
if (preg_match('/youtube\.com|youtu\.be|vimeo\.com/i', $videoUrl)) {
|
||||
$schema['embedUrl'] = $videoUrl;
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LocalBusiness schema from plugin parameters.
|
||||
*
|
||||
* @param object $params Plugin parameters object
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildLocalBusiness(object $params): ?array
|
||||
{
|
||||
$name = trim((string) $params->get('lb_name', ''));
|
||||
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => $params->get('lb_type', 'LocalBusiness'),
|
||||
'name' => $name,
|
||||
];
|
||||
|
||||
// Build PostalAddress
|
||||
$address = [];
|
||||
$street = trim((string) $params->get('lb_street', ''));
|
||||
$city = trim((string) $params->get('lb_city', ''));
|
||||
$region = trim((string) $params->get('lb_region', ''));
|
||||
$postal = trim((string) $params->get('lb_postal', ''));
|
||||
$country = trim((string) $params->get('lb_country', ''));
|
||||
|
||||
if ($street !== '') {
|
||||
$address['streetAddress'] = $street;
|
||||
}
|
||||
|
||||
if ($city !== '') {
|
||||
$address['addressLocality'] = $city;
|
||||
}
|
||||
|
||||
if ($region !== '') {
|
||||
$address['addressRegion'] = $region;
|
||||
}
|
||||
|
||||
if ($postal !== '') {
|
||||
$address['postalCode'] = $postal;
|
||||
}
|
||||
|
||||
if ($country !== '') {
|
||||
$address['addressCountry'] = $country;
|
||||
}
|
||||
|
||||
if (!empty($address)) {
|
||||
$address['@type'] = 'PostalAddress';
|
||||
$schema['address'] = $address;
|
||||
}
|
||||
|
||||
// Contact properties
|
||||
$phone = trim((string) $params->get('lb_phone', ''));
|
||||
$email = trim((string) $params->get('lb_email', ''));
|
||||
$url = trim((string) $params->get('lb_url', ''));
|
||||
|
||||
if ($phone !== '') {
|
||||
$schema['telephone'] = $phone;
|
||||
}
|
||||
|
||||
if ($email !== '') {
|
||||
$schema['email'] = $email;
|
||||
}
|
||||
|
||||
if ($url !== '') {
|
||||
$schema['url'] = $url;
|
||||
}
|
||||
|
||||
// Opening hours
|
||||
$openingHours = trim((string) $params->get('lb_opening_hours', ''));
|
||||
|
||||
if ($openingHours !== '') {
|
||||
$schema['openingHours'] = $openingHours;
|
||||
}
|
||||
|
||||
// GeoCoordinates
|
||||
$latitude = trim((string) $params->get('lb_latitude', ''));
|
||||
$longitude = trim((string) $params->get('lb_longitude', ''));
|
||||
|
||||
if ($latitude !== '' && $longitude !== '') {
|
||||
$schema['geo'] = [
|
||||
'@type' => 'GeoCoordinates',
|
||||
'latitude' => $latitude,
|
||||
'longitude' => $longitude,
|
||||
];
|
||||
}
|
||||
|
||||
// Price range
|
||||
$priceRange = trim((string) $params->get('lb_price_range', ''));
|
||||
|
||||
if ($priceRange !== '') {
|
||||
$schema['priceRange'] = $priceRange;
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FAQPage schema from question/answer pairs.
|
||||
*
|
||||
* @param array $questions Array of ['question' => '...', 'answer' => '...'] pairs
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildFaq(array $questions): ?array
|
||||
{
|
||||
if (empty($questions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mainEntity = [];
|
||||
|
||||
foreach ($questions as $item) {
|
||||
$question = trim($item['question'] ?? '');
|
||||
$answer = trim($item['answer'] ?? '');
|
||||
|
||||
if ($question === '' || $answer === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mainEntity[] = [
|
||||
'@type' => 'Question',
|
||||
'name' => $question,
|
||||
'acceptedAnswer' => [
|
||||
'@type' => 'Answer',
|
||||
'text' => $answer,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($mainEntity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'FAQPage',
|
||||
'mainEntity' => $mainEntity,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HowTo schema from step-by-step instructions.
|
||||
*
|
||||
* @param string $title HowTo title
|
||||
* @param array $steps Array of ['name' => 'Step title', 'text' => 'Step instructions']
|
||||
* @param string $imageUrl Optional image URL (absolute)
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildHowTo(string $title, array $steps, string $imageUrl = ''): ?array
|
||||
{
|
||||
if (empty($steps)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'HowTo',
|
||||
'name' => $title,
|
||||
];
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
$schema['image'] = $imageUrl;
|
||||
}
|
||||
|
||||
$schema['step'] = [];
|
||||
|
||||
foreach ($steps as $step) {
|
||||
$schema['step'][] = [
|
||||
'@type' => 'HowToStep',
|
||||
'name' => $step['name'],
|
||||
'text' => $step['text'],
|
||||
];
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Event schema from per-article event data.
|
||||
*
|
||||
* @param string $title Event/article title
|
||||
* @param string $description Event description
|
||||
* @param string $imageUrl Image URL (absolute)
|
||||
* @param object $eventData Decoded event_data with event_start, event_end, etc.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildEvent(string $title, string $description, string $imageUrl, object $eventData): ?array
|
||||
{
|
||||
$startDate = $eventData->event_start ?? '';
|
||||
|
||||
if (empty($startDate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Event',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'startDate' => $startDate,
|
||||
'url' => Uri::getInstance()->toString(),
|
||||
];
|
||||
|
||||
$endDate = $eventData->event_end ?? '';
|
||||
|
||||
if (!empty($endDate)) {
|
||||
$schema['endDate'] = $endDate;
|
||||
}
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
$schema['image'] = $imageUrl;
|
||||
}
|
||||
|
||||
$locationName = $eventData->event_location ?? '';
|
||||
$address = $eventData->event_address ?? '';
|
||||
|
||||
if (!empty($locationName) || !empty($address)) {
|
||||
$location = ['@type' => 'Place'];
|
||||
|
||||
if (!empty($locationName)) {
|
||||
$location['name'] = $locationName;
|
||||
}
|
||||
|
||||
if (!empty($address)) {
|
||||
$location['address'] = [
|
||||
'@type' => 'PostalAddress',
|
||||
'streetAddress' => $address,
|
||||
];
|
||||
}
|
||||
|
||||
$schema['location'] = $location;
|
||||
}
|
||||
|
||||
$price = $eventData->event_price ?? '';
|
||||
$currency = $eventData->event_currency ?? 'USD';
|
||||
$ticketUrl = $eventData->event_url ?? '';
|
||||
|
||||
if ($price !== '') {
|
||||
$offer = [
|
||||
'@type' => 'Offer',
|
||||
'price' => number_format((float) $price, 2, '.', ''),
|
||||
'priceCurrency' => $currency ?: 'USD',
|
||||
'availability' => 'https://schema.org/InStock',
|
||||
];
|
||||
|
||||
if (!empty($ticketUrl)) {
|
||||
$offer['url'] = $ticketUrl;
|
||||
}
|
||||
|
||||
$schema['offers'] = $offer;
|
||||
} elseif (!empty($ticketUrl)) {
|
||||
$schema['offers'] = [
|
||||
'@type' => 'Offer',
|
||||
'price' => '0.00',
|
||||
'priceCurrency' => $currency ?: 'USD',
|
||||
'availability' => 'https://schema.org/InStock',
|
||||
'url' => $ticketUrl,
|
||||
];
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Recipe schema from per-article recipe data.
|
||||
*
|
||||
* @param string $title Recipe/article title
|
||||
* @param string $description Recipe/article description
|
||||
* @param string $imageUrl Image URL (absolute)
|
||||
* @param object $recipeData Decoded recipe_data object
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildRecipe(string $title, string $description, string $imageUrl, object $recipeData): ?array
|
||||
{
|
||||
$fields = ['recipe_prep_time', 'recipe_cook_time', 'recipe_yield', 'recipe_calories', 'recipe_ingredients', 'recipe_category', 'recipe_cuisine'];
|
||||
$hasData = false;
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (!empty($recipeData->$field)) {
|
||||
$hasData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Recipe',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'url' => Uri::getInstance()->toString(),
|
||||
];
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
$schema['image'] = $imageUrl;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_prep_time)) {
|
||||
$schema['prepTime'] = $recipeData->recipe_prep_time;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_cook_time)) {
|
||||
$schema['cookTime'] = $recipeData->recipe_cook_time;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_prep_time) && !empty($recipeData->recipe_cook_time)) {
|
||||
try {
|
||||
$prep = new \DateInterval($recipeData->recipe_prep_time);
|
||||
$cook = new \DateInterval($recipeData->recipe_cook_time);
|
||||
$totalMinutes = ($prep->h * 60 + $prep->i) + ($cook->h * 60 + $cook->i);
|
||||
$hours = intdiv($totalMinutes, 60);
|
||||
$minutes = $totalMinutes % 60;
|
||||
$totalTime = 'PT';
|
||||
|
||||
if ($hours > 0) {
|
||||
$totalTime .= $hours . 'H';
|
||||
}
|
||||
|
||||
if ($minutes > 0) {
|
||||
$totalTime .= $minutes . 'M';
|
||||
}
|
||||
|
||||
if ($totalTime !== 'PT') {
|
||||
$schema['totalTime'] = $totalTime;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Invalid duration format
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_yield)) {
|
||||
$schema['recipeYield'] = $recipeData->recipe_yield;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_calories)) {
|
||||
$schema['nutrition'] = [
|
||||
'@type' => 'NutritionInformation',
|
||||
'calories' => $recipeData->recipe_calories . ' calories',
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_ingredients)) {
|
||||
$ingredients = array_filter(
|
||||
array_map('trim', preg_split('/\r\n|\r|\n/', $recipeData->recipe_ingredients)),
|
||||
fn($line) => $line !== ''
|
||||
);
|
||||
|
||||
if (!empty($ingredients)) {
|
||||
$schema['recipeIngredient'] = array_values($ingredients);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_category)) {
|
||||
$schema['recipeCategory'] = $recipeData->recipe_category;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_cuisine)) {
|
||||
$schema['recipeCuisine'] = $recipeData->recipe_cuisine;
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a schema array to a JSON-LD script tag string.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage plg_system_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\MokoOG\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/**
|
||||
* XML Sitemap builder.
|
||||
*
|
||||
* Generates a sitemap.xml containing all published articles, excluding
|
||||
* those marked with noindex robots directives in the mokoog_tags table.
|
||||
*/
|
||||
class SitemapBuilder
|
||||
{
|
||||
/**
|
||||
* Generate sitemap XML content.
|
||||
*
|
||||
* @param string $changefreq Default change frequency for entries
|
||||
*
|
||||
* @return string Complete sitemap XML
|
||||
*/
|
||||
public static function generate(string $changefreq = 'weekly'): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Get all published articles
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language']))
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->where($db->quoteName('a.state') . ' = 1');
|
||||
|
||||
$db->setQuery($query);
|
||||
$articles = $db->loadObjectList();
|
||||
|
||||
// Get noindex articles from mokoog_tags
|
||||
$noindexQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('content_id'))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
|
||||
->where($db->quoteName('robots') . ' LIKE ' . $db->quote('%noindex%'));
|
||||
|
||||
$db->setQuery($noindexQuery);
|
||||
$noindexIds = $db->loadColumn();
|
||||
|
||||
$root = rtrim(Uri::root(), '/');
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
|
||||
// Homepage
|
||||
$xml .= ' <url>' . "\n";
|
||||
$xml .= ' <loc>' . $root . '/</loc>' . "\n";
|
||||
$xml .= ' <changefreq>daily</changefreq>' . "\n";
|
||||
$xml .= ' <priority>1.0</priority>' . "\n";
|
||||
$xml .= ' </url>' . "\n";
|
||||
|
||||
foreach ($articles as $article) {
|
||||
// Skip noindexed
|
||||
if (in_array((int) $article->id, $noindexIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $root . '/index.php?option=com_content&view=article&id=' . $article->id;
|
||||
$lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00'
|
||||
? date('Y-m-d', strtotime($article->modified)) : '';
|
||||
|
||||
$xml .= ' <url>' . "\n";
|
||||
$xml .= ' <loc>' . htmlspecialchars($url, ENT_XML1) . '</loc>' . "\n";
|
||||
|
||||
if ($lastmod) {
|
||||
$xml .= ' <lastmod>' . $lastmod . '</lastmod>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= ' <changefreq>' . $changefreq . '</changefreq>' . "\n";
|
||||
$xml .= ' <priority>0.8</priority>' . "\n";
|
||||
$xml .= ' </url>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= '</urlset>';
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write sitemap XML to the site root.
|
||||
*
|
||||
* @param string $xml The sitemap XML content
|
||||
*
|
||||
* @return bool True on success
|
||||
*/
|
||||
public static function writeToFile(string $xml): bool
|
||||
{
|
||||
$path = JPATH_ROOT . '/sitemap.xml';
|
||||
|
||||
return (bool) file_put_contents($path, $xml);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoJoomOpenGraph</name>
|
||||
<version>01.03.01</version>
|
||||
<version>01.04.08</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteOpenGraph</name>
|
||||
<packagename>mokoog</packagename>
|
||||
<version>01.03.01</version>
|
||||
<version>01.04.08</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage Tests
|
||||
* @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 Mokoconsulting\MokoOG\Tests\Unit\Helper;
|
||||
|
||||
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class JsonLdBuilderTest extends TestCase
|
||||
{
|
||||
// ── FAQPage ──────────────────────────────────────────────────────
|
||||
|
||||
public function testBuildFaqReturnsNullForEmptyArray(): void
|
||||
{
|
||||
$this->assertNull(JsonLdBuilder::buildFaq([]));
|
||||
}
|
||||
|
||||
public function testBuildFaqSkipsEmptyQuestions(): void
|
||||
{
|
||||
$faqs = [
|
||||
['question' => '', 'answer' => 'An answer'],
|
||||
['question' => 'Valid?', 'answer' => ''],
|
||||
['question' => ' ', 'answer' => 'Still empty'],
|
||||
];
|
||||
|
||||
$this->assertNull(JsonLdBuilder::buildFaq($faqs));
|
||||
}
|
||||
|
||||
public function testBuildFaqReturnsValidSchema(): void
|
||||
{
|
||||
$faqs = [
|
||||
['question' => 'What is OG?', 'answer' => 'Open Graph protocol.'],
|
||||
['question' => 'Why use it?', 'answer' => 'Better social previews.'],
|
||||
];
|
||||
|
||||
$result = JsonLdBuilder::buildFaq($faqs);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('https://schema.org', $result['@context']);
|
||||
$this->assertSame('FAQPage', $result['@type']);
|
||||
$this->assertCount(2, $result['mainEntity']);
|
||||
$this->assertSame('Question', $result['mainEntity'][0]['@type']);
|
||||
$this->assertSame('What is OG?', $result['mainEntity'][0]['name']);
|
||||
$this->assertSame('Open Graph protocol.', $result['mainEntity'][0]['acceptedAnswer']['text']);
|
||||
}
|
||||
|
||||
// ── HowTo ────────────────────────────────────────────────────────
|
||||
|
||||
public function testBuildHowToReturnsNullForEmptySteps(): void
|
||||
{
|
||||
$this->assertNull(JsonLdBuilder::buildHowTo('Test', []));
|
||||
$this->assertNull(JsonLdBuilder::buildHowTo('Test', ['', ' ']));
|
||||
}
|
||||
|
||||
public function testBuildHowToReturnsValidSchema(): void
|
||||
{
|
||||
$result = JsonLdBuilder::buildHowTo('Install Joomla', ['Download ZIP', 'Upload files', 'Run installer']);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('HowTo', $result['@type']);
|
||||
$this->assertSame('Install Joomla', $result['name']);
|
||||
$this->assertCount(3, $result['step']);
|
||||
$this->assertSame(1, $result['step'][0]['position']);
|
||||
$this->assertSame('HowToStep', $result['step'][0]['@type']);
|
||||
$this->assertSame('Download ZIP', $result['step'][0]['text']);
|
||||
$this->assertArrayNotHasKey('image', $result);
|
||||
}
|
||||
|
||||
public function testBuildHowToIncludesImageWhenProvided(): void
|
||||
{
|
||||
$result = JsonLdBuilder::buildHowTo('Fix a bike', ['Remove wheel'], 'https://example.com/bike.jpg');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('https://example.com/bike.jpg', $result['image']);
|
||||
}
|
||||
|
||||
// ── Recipe ───────────────────────────────────────────────────────
|
||||
|
||||
public function testBuildRecipeReturnsNullWhenNoData(): void
|
||||
{
|
||||
$data = (object) ['name' => '', 'description' => ''];
|
||||
|
||||
$this->assertNull(JsonLdBuilder::buildRecipe($data));
|
||||
}
|
||||
|
||||
public function testBuildRecipeCalculatesTotalTime(): void
|
||||
{
|
||||
$data = (object) [
|
||||
'name' => 'Pasta',
|
||||
'prepTime' => 'PT15M',
|
||||
'cookTime' => 'PT30M',
|
||||
];
|
||||
|
||||
$result = JsonLdBuilder::buildRecipe($data);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('Recipe', $result['@type']);
|
||||
$this->assertSame('PT45M', $result['totalTime']);
|
||||
}
|
||||
|
||||
public function testBuildRecipeSplitsIngredientsByNewline(): void
|
||||
{
|
||||
$data = (object) [
|
||||
'name' => 'Salad',
|
||||
'ingredients' => "Lettuce\nTomato\nOnion",
|
||||
];
|
||||
|
||||
$result = JsonLdBuilder::buildRecipe($data);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame(['Lettuce', 'Tomato', 'Onion'], $result['recipeIngredient']);
|
||||
}
|
||||
|
||||
// ── Event ────────────────────────────────────────────────────────
|
||||
|
||||
public function testBuildEventReturnsNullWithoutStartDate(): void
|
||||
{
|
||||
$data = (object) ['name' => 'Conference', 'startDate' => ''];
|
||||
|
||||
$this->assertNull(JsonLdBuilder::buildEvent($data));
|
||||
}
|
||||
|
||||
public function testBuildEventIncludesLocationAndOffers(): void
|
||||
{
|
||||
$data = (object) [
|
||||
'name' => 'Tech Summit',
|
||||
'startDate' => '2026-09-01T09:00:00',
|
||||
'endDate' => '2026-09-01T17:00:00',
|
||||
'location' => (object) [
|
||||
'name' => 'Convention Center',
|
||||
'address' => '123 Main St',
|
||||
],
|
||||
'offers' => (object) [
|
||||
'price' => '99.00',
|
||||
'currency' => 'EUR',
|
||||
'url' => 'https://example.com/tickets',
|
||||
],
|
||||
];
|
||||
|
||||
$result = JsonLdBuilder::buildEvent($data);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('Event', $result['@type']);
|
||||
$this->assertSame('2026-09-01T09:00:00', $result['startDate']);
|
||||
$this->assertSame('2026-09-01T17:00:00', $result['endDate']);
|
||||
$this->assertSame('Place', $result['location']['@type']);
|
||||
$this->assertSame('Convention Center', $result['location']['name']);
|
||||
$this->assertSame('Offer', $result['offers']['@type']);
|
||||
$this->assertSame('99.00', $result['offers']['price']);
|
||||
$this->assertSame('EUR', $result['offers']['priceCurrency']);
|
||||
}
|
||||
|
||||
// ── LocalBusiness ────────────────────────────────────────────────
|
||||
|
||||
public function testBuildLocalBusinessReturnsNullWithoutName(): void
|
||||
{
|
||||
$params = $this->createParamsMock([]);
|
||||
|
||||
$this->assertNull(JsonLdBuilder::buildLocalBusiness($params));
|
||||
}
|
||||
|
||||
public function testBuildLocalBusinessIncludesAddress(): void
|
||||
{
|
||||
$params = $this->createParamsMock([
|
||||
'business_name' => 'Moko Consulting',
|
||||
'street_address' => '456 Oak Ave',
|
||||
'city' => 'Austin',
|
||||
'region' => 'TX',
|
||||
'postal_code' => '78701',
|
||||
'country' => 'US',
|
||||
'telephone' => '+1-555-0100',
|
||||
]);
|
||||
|
||||
$result = JsonLdBuilder::buildLocalBusiness($params);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('LocalBusiness', $result['@type']);
|
||||
$this->assertSame('Moko Consulting', $result['name']);
|
||||
$this->assertSame('PostalAddress', $result['address']['@type']);
|
||||
$this->assertSame('456 Oak Ave', $result['address']['streetAddress']);
|
||||
$this->assertSame('Austin', $result['address']['addressLocality']);
|
||||
$this->assertSame('TX', $result['address']['addressRegion']);
|
||||
$this->assertSame('78701', $result['address']['postalCode']);
|
||||
$this->assertSame('US', $result['address']['addressCountry']);
|
||||
$this->assertSame('+1-555-0100', $result['telephone']);
|
||||
}
|
||||
|
||||
// ── VideoObject ──────────────────────────────────────────────────
|
||||
|
||||
public function testBuildVideoReturnsNullForEmptyUrl(): void
|
||||
{
|
||||
$this->assertNull(JsonLdBuilder::buildVideo(''));
|
||||
}
|
||||
|
||||
public function testBuildVideoAddsEmbedUrlForYoutube(): void
|
||||
{
|
||||
$result = JsonLdBuilder::buildVideo(
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'Test Video',
|
||||
'A description'
|
||||
);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame('VideoObject', $result['@type']);
|
||||
$this->assertSame('https://www.youtube.com/watch?v=dQw4w9WgXcQ', $result['contentUrl']);
|
||||
$this->assertSame('https://www.youtube.com/embed/dQw4w9WgXcQ', $result['embedUrl']);
|
||||
$this->assertSame('Test Video', $result['name']);
|
||||
}
|
||||
|
||||
// ── toScriptTag ──────────────────────────────────────────────────
|
||||
|
||||
public function testToScriptTagEscapesClosingScriptTags(): void
|
||||
{
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'headline' => 'Test </script><script>alert(1)</script>',
|
||||
];
|
||||
|
||||
$output = JsonLdBuilder::toScriptTag($schema);
|
||||
|
||||
$this->assertStringStartsWith('<script type="application/ld+json">', $output);
|
||||
$this->assertStringEndsWith('</script>', $output);
|
||||
// The closing </script> inside the JSON must be escaped
|
||||
$this->assertStringNotContainsString('</script><script>', $output);
|
||||
$this->assertStringContainsString('<\\/script>', $output);
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a mock object that mimics Joomla's Registry->get($key, $default).
|
||||
*/
|
||||
private function createParamsMock(array $values): object
|
||||
{
|
||||
return new class ($values) {
|
||||
private array $data;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = ''): mixed
|
||||
{
|
||||
return $this->data[$key] ?? $default;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage Tests
|
||||
* @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
|
||||
*/
|
||||
|
||||
define('_JEXEC', 1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
Reference in New Issue
Block a user