diff --git a/.gitignore b/.gitignore index 8affbb6..d027d61 100644 --- a/.gitignore +++ b/.gitignore @@ -156,6 +156,7 @@ vendor/ composer.lock *.phar codeception.phar +.phpunit.cache/ .phpunit.result.cache .php_cs.cache .php-cs-fixer.cache diff --git a/.mokogitea/CLAUDE.md b/.mokogitea/CLAUDE.md index 8aefbbf..cc3cad6 100644 --- a/.mokogitea/CLAUDE.md +++ b/.mokogitea/CLAUDE.md @@ -1,4 +1,4 @@ -# MokoJoomOpenGraph +# MokoSuiteOpenGraph Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Per-article SEO with auto-generation fallback. @@ -9,7 +9,7 @@ Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Per | **Package** | `pkg_mokoog` | | **Language** | PHP 8.1+ | | **Branch** | develop on `dev`, merge to `main` (protected) | -| **Wiki** | [MokoJoomOpenGraph Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/wiki) | +| **Wiki** | [MokoSuiteOpenGraph Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/wiki) | ## Commands diff --git a/.mokogitea/workflows/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml deleted file mode 100644 index 1af323c..0000000 --- a/.mokogitea/workflows/deploy-manual.yml +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# 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: - MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }} - MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }} - MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_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 diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 11958bd..64970a3 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# VERSION: 01.04.17 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/CHANGELOG.md b/CHANGELOG.md index 21665a0..9a5154a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,12 @@ # Changelog -## [Unreleased] - -## [01.04.00] --- 2026-06-23 - - - + All notable changes to MokoSuiteOpenGraph will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [01.04.00] --- 2026-06-23 +## [Unreleased] ### Security - Fix JSON-LD XSS vulnerability via `` injection in content data (#34) @@ -25,6 +20,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - 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` @@ -50,6 +60,17 @@ 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 +- Fix SQL driver attribute `mysql` → `mysqli` in component manifest preventing fresh installs +- 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) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 809e983..387a451 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,7 +14,7 @@ DEFGROUP: Template-Joomla INGROUP: Template-Joomla.Documentation REPO: https://github.com/mokoconsulting-tech/Template-Joomla/ - VERSION: 01.01.00 + VERSION: 01.04.17 PATH: ./CODE_OF_CONDUCT.md BRIEF: Community expectations and enforcement guidelines NOTE: Adapted with attribution from the Contributor Covenant v2.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf60cc5..c0b4858 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,161 +1,161 @@ -# Contributing to Moko Consulting Projects - -Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy. - -## Branching Workflow - -``` -feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main -``` - -### Step by step - -1. **Create a feature branch** from `dev`: - ```bash - git checkout dev && git pull - git checkout -b feature/my-change - ``` - -2. **Work and commit** on your feature branch. Push to origin. - -3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it. - -4. **When ready for release**, open a **draft PR**: `dev` → `main`. - - This automatically renames the source branch to `rc` (release candidate) - - An RC pre-release is built and uploaded - -5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage: - - Rename `dev` to `alpha` for early testing → alpha pre-release is built - - Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built - - When the draft PR is created, the branch is renamed to `rc` - -6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`. - -7. **Merging to main** triggers the stable release pipeline: - - Minor version bump (e.g., `02.09.xx` → `02.10.00`) - - Stability suffix stripped (clean version) - - Gitea release created with ZIP/tar.gz packages - - `updates.xml` updated (Joomla extensions) - - `dev` branch recreated from `main` - -### Branch summary - -| Branch | Purpose | Created by | -|--------|---------|-----------| -| `feature/*` | New features and fixes | Developer | -| `dev` | Integration branch | Auto-recreated after release | -| `alpha` | Alpha pre-release testing | Manual rename from `dev` | -| `beta` | Beta pre-release testing | Manual rename from `alpha` | -| `rc` | Release candidate | Auto-renamed on draft PR to main | -| `main` | Stable releases | Protected, merge only | -| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI | - -### Protected branches - -| Branch | Direct push | Merge via | -|--------|------------|-----------| -| `main` | Blocked (CI bot whitelisted) | PR merge only | -| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* | -| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR | -| `alpha` | Blocked (CI bot whitelisted) | Manual rename | -| `beta` | Blocked (CI bot whitelisted) | Manual rename | -| `feature/*` | Open | N/A (source branch) | - -## Version Policy - -### Format - -All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded: - -- **XX** — Major version (breaking changes) -- **YY** — Minor version (new features, bumped on release to main) -- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches) - -Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major. - -### Stability suffixes - -Each branch appends a suffix to indicate stability: - -| Branch | Suffix | Example | -|--------|--------|---------| -| `main` | (none) | `02.09.00` | -| `dev` | `-dev` | `02.09.01-dev` | -| `feature/*` | `-dev` | `02.09.01-dev` | -| `alpha` | `-alpha` | `02.09.01-alpha` | -| `beta` | `-beta` | `02.09.01-beta` | -| `rc` | `-rc` | `02.09.01-rc` | - -### Auto version bump - -On every push to `dev`, `feature/*`, or `patch/*`: - -1. Patch version incremented -2. Stability suffix `-dev` applied -3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.) -4. Commit created with `[skip ci]` to avoid loops - -### Release version flow - -Version bumps happen at specific release events: - -| Event | Bump | Example | -|-------|------|---------| -| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` | -| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` | -| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) | -| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` | - -### Release stream copies - -When a higher-stability release is published, copies are created for all lesser streams with the same base version: - -- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta` -- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc` - -This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed). - -### Version files - -The version tools update all files containing version stamps: - -- `.mokogitea/manifest.xml` (canonical source) -- Joomla XML manifests (`` tag) -- `README.md`, `CHANGELOG.md` (`VERSION:` pattern) -- `package.json`, `pyproject.toml` -- Any text file with a `VERSION: XX.YY.ZZ` label - -Files synced from other repos (with a `# REPO:` header) are not touched. - -## Code Standards - -- **PHP**: PSR-12, tabs for indentation -- **Copyright**: all files must include the Moko Consulting copyright header -- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo) -- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names - -## Commit Messages - -Use conventional commit format: - -``` -type(scope): short description - -Optional body with context. - -Authored-by: Moko Consulting -``` - -Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci` - -Special flags in commit messages: -- `[skip ci]` — skip all CI workflows -- `[skip bump]` — skip auto version bump only - -## Reporting Issues - -Use the repository's issue tracker with the appropriate template. - ---- - -*Moko Consulting * +# Contributing to Moko Consulting Projects + +Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy. + +## Branching Workflow + +``` +feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main +``` + +### Step by step + +1. **Create a feature branch** from `dev`: + ```bash + git checkout dev && git pull + git checkout -b feature/my-change + ``` + +2. **Work and commit** on your feature branch. Push to origin. + +3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it. + +4. **When ready for release**, open a **draft PR**: `dev` → `main`. + - This automatically renames the source branch to `rc` (release candidate) + - An RC pre-release is built and uploaded + +5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage: + - Rename `dev` to `alpha` for early testing → alpha pre-release is built + - Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built + - When the draft PR is created, the branch is renamed to `rc` + +6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`. + +7. **Merging to main** triggers the stable release pipeline: + - Minor version bump (e.g., `02.09.xx` → `02.10.00`) + - Stability suffix stripped (clean version) + - Gitea release created with ZIP/tar.gz packages + - `updates.xml` updated (Joomla extensions) + - `dev` branch recreated from `main` + +### Branch summary + +| Branch | Purpose | Created by | +|--------|---------|-----------| +| `feature/*` | New features and fixes | Developer | +| `dev` | Integration branch | Auto-recreated after release | +| `alpha` | Alpha pre-release testing | Manual rename from `dev` | +| `beta` | Beta pre-release testing | Manual rename from `alpha` | +| `rc` | Release candidate | Auto-renamed on draft PR to main | +| `main` | Stable releases | Protected, merge only | +| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI | + +### Protected branches + +| Branch | Direct push | Merge via | +|--------|------------|-----------| +| `main` | Blocked (CI bot whitelisted) | PR merge only | +| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* | +| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR | +| `alpha` | Blocked (CI bot whitelisted) | Manual rename | +| `beta` | Blocked (CI bot whitelisted) | Manual rename | +| `feature/*` | Open | N/A (source branch) | + +## Version Policy + +### Format + +All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded: + +- **XX** — Major version (breaking changes) +- **YY** — Minor version (new features, bumped on release to main) +- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches) + +Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major. + +### Stability suffixes + +Each branch appends a suffix to indicate stability: + +| Branch | Suffix | Example | +|--------|--------|---------| +| `main` | (none) | `02.09.00` | +| `dev` | `-dev` | `02.09.01-dev` | +| `feature/*` | `-dev` | `02.09.01-dev` | +| `alpha` | `-alpha` | `02.09.01-alpha` | +| `beta` | `-beta` | `02.09.01-beta` | +| `rc` | `-rc` | `02.09.01-rc` | + +### Auto version bump + +On every push to `dev`, `feature/*`, or `patch/*`: + +1. Patch version incremented +2. Stability suffix `-dev` applied +3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.) +4. Commit created with `[skip ci]` to avoid loops + +### Release version flow + +Version bumps happen at specific release events: + +| Event | Bump | Example | +|-------|------|---------| +| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` | +| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` | +| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) | +| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` | + +### Release stream copies + +When a higher-stability release is published, copies are created for all lesser streams with the same base version: + +- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta` +- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc` + +This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed). + +### Version files + +The version tools update all files containing version stamps: + +- `.mokogitea/manifest.xml` (canonical source) +- Joomla XML manifests (`` tag) +- `README.md`, `CHANGELOG.md` (`VERSION:` pattern) +- `package.json`, `pyproject.toml` +- Any text file with a `VERSION: XX.YY.ZZ` label + +Files synced from other repos (with a `# REPO:` header) are not touched. + +## Code Standards + +- **PHP**: PSR-12, tabs for indentation +- **Copyright**: all files must include the Moko Consulting copyright header +- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo) +- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names + +## Commit Messages + +Use conventional commit format: + +``` +type(scope): short description + +Optional body with context. + +Authored-by: Moko Consulting +``` + +Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci` + +Special flags in commit messages: +- `[skip ci]` — skip all CI workflows +- `[skip bump]` — skip auto version bump only + +## Reporting Issues + +Use the repository's issue tracker with the appropriate template. + +--- + +*Moko Consulting * diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 47fa254..a072363 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -19,7 +19,7 @@ DEFGROUP: mokoconsulting-tech.Template-Joomla INGROUP: MokoStandards.Governance REPO: https://github.com/mokoconsulting-tech/Template-Joomla - VERSION: 01.01.00 + VERSION: 01.04.17 PATH: /GOVERNANCE.md BRIEF: Project governance rules, roles, and decision process for Template-Joomla --> diff --git a/README.md b/README.md index 3eeef59..1957163 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # MokoSuiteOpenGraph - + -Open Graph, Twitter Card, and social sharing meta tag management for Joomla 4/5/6. +Open Graph, Twitter Card, and social sharing meta tag management for Joomla 6 and higher. ## Overview @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 86b35ed..694dd16 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla INGROUP: Template-Joomla.Documentation REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla PATH: /SECURITY.md -VERSION: 01.01.00 +VERSION: 01.04.17 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/composer.json b/composer.json index 10afe3a..a5fd414 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "mokoconsulting/mokojoomgallery", - "description": "Photo gallery management for Joomla — galleries, images, thumbnails, lightbox, and frontend display", + "name": "mokoconsulting/mokoog", + "description": "Open Graph, Twitter Card, and social sharing meta tag management for Joomla", "type": "joomla-package", "version": "01.00.00", "license": "GPL-3.0-or-later", @@ -15,11 +15,25 @@ "php": ">=8.1" }, "require-dev": { - "squizlabs/php_codesniffer": "^3.7", + "joomla/coding-standards": "^3.0", "phpstan/phpstan": "^1.10", - "joomla/coding-standards": "3.0.x-dev" + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7" }, - "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Joomla\\Plugin\\System\\MokoOG\\": "source/packages/plg_system_mokoog/src/", + "Joomla\\Plugin\\Content\\MokoOG\\": "source/packages/plg_content_mokoog/src/", + "Joomla\\Plugin\\WebServices\\MokoOG\\": "source/packages/plg_webservices_mokoog/src/", + "Joomla\\Component\\MokoOG\\Administrator\\": "source/packages/com_mokoog/src/" + } + }, + "autoload-dev": { + "psr-4": { + "Mokoconsulting\\MokoOG\\Tests\\": "tests/" + } + }, + "minimum-stability": "alpha", "prefer-stable": true, "config": { "sort-packages": true diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..4969c24 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,670 @@ +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. + + **Requires Joomla 6.0 or higher.** + + 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 `` 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." diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..9f4fb2a --- /dev/null +++ b/phpunit.xml.dist @@ -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> diff --git a/source/language/en-GB/pkg_mokoog.sys.ini b/source/language/en-GB/pkg_mokoog.sys.ini index 47aa587..0579559 100644 --- a/source/language/en-GB/pkg_mokoog.sys.ini +++ b/source/language/en-GB/pkg_mokoog.sys.ini @@ -1,7 +1,8 @@ -; MokoJoomOpenGraph - Package System Language File +; MokoSuiteOpenGraph - Package System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PKG_MOKOOG="MokoJoomOpenGraph" +PKG_MOKOOG="MokoSuiteOpenGraph" PKG_MOKOOG_DESCRIPTION="Complete Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Control how every page appears when shared on Facebook, Twitter/X, LinkedIn, WhatsApp, and more." -PKG_MOKOOG_PHP_VERSION_ERROR="MokoJoomOpenGraph requires PHP %s or later." +PKG_MOKOOG_PHP_VERSION_ERROR="MokoSuiteOpenGraph requires PHP %s or later." +PKG_MOKOOG_JOOMLA_VERSION_ERROR="MokoSuiteOpenGraph requires Joomla %s or later." diff --git a/source/language/en-US/pkg_mokoog.sys.ini b/source/language/en-US/pkg_mokoog.sys.ini index 47aa587..0579559 100644 --- a/source/language/en-US/pkg_mokoog.sys.ini +++ b/source/language/en-US/pkg_mokoog.sys.ini @@ -1,7 +1,8 @@ -; MokoJoomOpenGraph - Package System Language File +; MokoSuiteOpenGraph - Package System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PKG_MOKOOG="MokoJoomOpenGraph" +PKG_MOKOOG="MokoSuiteOpenGraph" PKG_MOKOOG_DESCRIPTION="Complete Open Graph, Twitter Card, and social sharing meta tag management for Joomla. Control how every page appears when shared on Facebook, Twitter/X, LinkedIn, WhatsApp, and more." -PKG_MOKOOG_PHP_VERSION_ERROR="MokoJoomOpenGraph requires PHP %s or later." +PKG_MOKOOG_PHP_VERSION_ERROR="MokoSuiteOpenGraph requires PHP %s or later." +PKG_MOKOOG_JOOMLA_VERSION_ERROR="MokoSuiteOpenGraph requires Joomla %s or later." diff --git a/source/packages/com_mokoog/api/src/Controller/TagsController.php b/source/packages/com_mokoog/api/src/Controller/TagsController.php index 9fbe168..2e62de3 100644 --- a/source/packages/com_mokoog/api/src/Controller/TagsController.php +++ b/source/packages/com_mokoog/api/src/Controller/TagsController.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog.api * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php b/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php index 97a0aa2..4a33148 100644 --- a/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php +++ b/source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog.api * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/forms/filter_tags.xml b/source/packages/com_mokoog/forms/filter_tags.xml index b4d4015..ae32efc 100644 --- a/source/packages/com_mokoog/forms/filter_tags.xml +++ b/source/packages/com_mokoog/forms/filter_tags.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/forms/tag.xml b/source/packages/com_mokoog/forms/tag.xml index 83f8f93..7b4a35f 100644 --- a/source/packages/com_mokoog/forms/tag.xml +++ b/source/packages/com_mokoog/forms/tag.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -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" @@ -85,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" @@ -94,7 +94,7 @@ description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC" filter="string" rows="3" - maxlength="200" + maxlength="255" /> <field name="robots" diff --git a/source/packages/com_mokoog/language/en-GB/com_mokoog.ini b/source/packages/com_mokoog/language/en-GB/com_mokoog.ini index e8db069..4f0d4d3 100644 --- a/source/packages/com_mokoog/language/en-GB/com_mokoog.ini +++ b/source/packages/com_mokoog/language/en-GB/com_mokoog.ini @@ -1,9 +1,9 @@ -; MokoJoomOpenGraph - Component Language File +; MokoSuiteOpenGraph - Component Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -COM_MOKOOG="MokoJoomOpenGraph" -COM_MOKOOG_TAGS_TITLE="MokoJoomOpenGraph - Tag Manager" +COM_MOKOOG="MokoSuiteOpenGraph" +COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager" COM_MOKOOG_SUBMENU_TAGS="Tags" COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" @@ -59,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" diff --git a/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini b/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini index 0aacbc4..0e382dd 100644 --- a/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini +++ b/source/packages/com_mokoog/language/en-GB/com_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoJoomOpenGraph - Component System Language File +; MokoSuiteOpenGraph - Component System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -COM_MOKOOG="MokoJoomOpenGraph" +COM_MOKOOG="MokoSuiteOpenGraph" COM_MOKOOG_DESCRIPTION="Manage Open Graph and social sharing tags for all your content. View, edit, and batch-process OG metadata." diff --git a/source/packages/com_mokoog/language/en-US/com_mokoog.ini b/source/packages/com_mokoog/language/en-US/com_mokoog.ini index e8db069..4f0d4d3 100644 --- a/source/packages/com_mokoog/language/en-US/com_mokoog.ini +++ b/source/packages/com_mokoog/language/en-US/com_mokoog.ini @@ -1,9 +1,9 @@ -; MokoJoomOpenGraph - Component Language File +; MokoSuiteOpenGraph - Component Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -COM_MOKOOG="MokoJoomOpenGraph" -COM_MOKOOG_TAGS_TITLE="MokoJoomOpenGraph - Tag Manager" +COM_MOKOOG="MokoSuiteOpenGraph" +COM_MOKOOG_TAGS_TITLE="MokoSuiteOpenGraph - Tag Manager" COM_MOKOOG_SUBMENU_TAGS="Tags" COM_MOKOOG_NO_TAGS="No Open Graph tags have been created yet. Tags are created automatically when you edit articles or menu items." COM_MOKOOG_TABLE_CAPTION="Table of Open Graph tags" @@ -59,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" diff --git a/source/packages/com_mokoog/language/en-US/com_mokoog.sys.ini b/source/packages/com_mokoog/language/en-US/com_mokoog.sys.ini index 0aacbc4..0e382dd 100644 --- a/source/packages/com_mokoog/language/en-US/com_mokoog.sys.ini +++ b/source/packages/com_mokoog/language/en-US/com_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoJoomOpenGraph - Component System Language File +; MokoSuiteOpenGraph - Component System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -COM_MOKOOG="MokoJoomOpenGraph" +COM_MOKOOG="MokoSuiteOpenGraph" COM_MOKOOG_DESCRIPTION="Manage Open Graph and social sharing tags for all your content. View, edit, and batch-process OG metadata." diff --git a/source/packages/com_mokoog/mokoog.xml b/source/packages/com_mokoog/mokoog.xml index 11d41e9..7c6e135 100644 --- a/source/packages/com_mokoog/mokoog.xml +++ b/source/packages/com_mokoog/mokoog.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -8,7 +8,7 @@ --> <extension type="component" method="upgrade"> <name>com_mokoog</name> - <version>01.04.00</version> + <version>01.04.17</version> <creationDate>2026-05-23</creationDate> <author>Moko Consulting</author> <authorEmail>hello@mokoconsulting.tech</authorEmail> @@ -23,17 +23,17 @@ <install> <sql> - <file driver="mysql" charset="utf8">sql/install.mysql.sql</file> + <file driver="mysqli" charset="utf8">sql/install.mysql.sql</file> </sql> </install> <uninstall> <sql> - <file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file> + <file driver="mysqli" charset="utf8">sql/uninstall.mysql.sql</file> </sql> </uninstall> <update> <schemas> - <schemapath type="mysql">sql/updates/mysql</schemapath> + <schemapath type="mysqli">sql/updates/mysql</schemapath> </schemas> </update> diff --git a/source/packages/com_mokoog/script.php b/source/packages/com_mokoog/script.php index 5073df5..ace5636 100644 --- a/source/packages/com_mokoog/script.php +++ b/source/packages/com_mokoog/script.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -23,7 +23,7 @@ class Com_MokoOGInstallerScript */ public function install(InstallerAdapter $parent): void { - echo '<p>MokoJoomOpenGraph component installed successfully.</p>'; + echo '<p>MokoSuiteOpenGraph component installed successfully.</p>'; } /** @@ -35,6 +35,6 @@ class Com_MokoOGInstallerScript */ public function update(InstallerAdapter $parent): void { - echo '<p>MokoJoomOpenGraph component updated successfully.</p>'; + echo '<p>MokoSuiteOpenGraph component updated successfully.</p>'; } } diff --git a/source/packages/com_mokoog/services/provider.php b/source/packages/com_mokoog/services/provider.php index 27c3354..af865fc 100644 --- a/source/packages/com_mokoog/services/provider.php +++ b/source/packages/com_mokoog/services/provider.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/sql/install.mysql.sql b/source/packages/com_mokoog/sql/install.mysql.sql index 1bce7f2..524cd99 100644 --- a/source/packages/com_mokoog/sql/install.mysql.sql +++ b/source/packages/com_mokoog/sql/install.mysql.sql @@ -1,5 +1,5 @@ -- --- MokoJoomOpenGraph - Database Schema +-- MokoSuiteOpenGraph - Database Schema -- Copyright (C) 2026 Moko Consulting. All rights reserved. -- License: GPL-3.0-or-later -- @@ -13,6 +13,9 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` ( `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 '', diff --git a/source/packages/com_mokoog/sql/uninstall.mysql.sql b/source/packages/com_mokoog/sql/uninstall.mysql.sql index 8652ac6..cd243a3 100644 --- a/source/packages/com_mokoog/sql/uninstall.mysql.sql +++ b/source/packages/com_mokoog/sql/uninstall.mysql.sql @@ -1,5 +1,5 @@ -- --- MokoJoomOpenGraph - Uninstall +-- MokoSuiteOpenGraph - Uninstall -- DROP TABLE IF EXISTS `#__mokoog_tags`; diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql index 5bac763..c9d404a 100644 --- a/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql +++ b/source/packages/com_mokoog/sql/updates/mysql/01.01.00.sql @@ -1,5 +1,5 @@ -- --- MokoJoomOpenGraph 01.01.00 — Add SEO meta management columns +-- MokoSuiteOpenGraph 01.01.00 — Add SEO meta management columns -- ALTER TABLE `#__mokoog_tags` diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql index ca87208..8404704 100644 --- a/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql +++ b/source/packages/com_mokoog/sql/updates/mysql/01.02.00.sql @@ -1,5 +1,5 @@ -- --- MokoJoomOpenGraph 01.02.00 — Add multilingual OG tag support +-- MokoSuiteOpenGraph 01.02.00 — Add multilingual OG tag support -- ALTER TABLE `#__mokoog_tags` diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.03.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.03.00.sql index 6d21c00..f5797f4 100644 --- a/source/packages/com_mokoog/sql/updates/mysql/01.03.00.sql +++ b/source/packages/com_mokoog/sql/updates/mysql/01.03.00.sql @@ -1,5 +1,5 @@ -- --- MokoJoomOpenGraph 01.03.00 - Add og_video column +-- MokoSuiteOpenGraph 01.03.00 - Add og_video column -- ALTER TABLE `#__mokoog_tags` ADD COLUMN `og_video` VARCHAR(512) NOT NULL DEFAULT '' AFTER `og_type`; diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.00.sql new file mode 100644 index 0000000..5049929 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.00.sql @@ -0,0 +1,6 @@ +-- +-- MokoSuiteOpenGraph 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`; diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.09.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.09.sql new file mode 100644 index 0000000..d5246e9 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.09.sql @@ -0,0 +1 @@ +/* 01.04.09 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.10.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.10.sql new file mode 100644 index 0000000..361f3a0 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.10.sql @@ -0,0 +1 @@ +/* 01.04.10 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.11.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.11.sql new file mode 100644 index 0000000..1d05f29 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.11.sql @@ -0,0 +1 @@ +/* 01.04.11 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.12.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.12.sql new file mode 100644 index 0000000..555657a --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.12.sql @@ -0,0 +1 @@ +/* 01.04.12 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.13.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.13.sql new file mode 100644 index 0000000..529387c --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.13.sql @@ -0,0 +1 @@ +/* 01.04.13 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.14.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.14.sql new file mode 100644 index 0000000..617f360 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.14.sql @@ -0,0 +1 @@ +/* 01.04.14 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.15.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.15.sql new file mode 100644 index 0000000..2f2ebe4 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.15.sql @@ -0,0 +1 @@ +/* 01.04.15 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.16.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.16.sql new file mode 100644 index 0000000..cc731eb --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.16.sql @@ -0,0 +1 @@ +/* 01.04.16 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.17.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.17.sql new file mode 100644 index 0000000..27e15a2 --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.17.sql @@ -0,0 +1 @@ +/* 01.04.17 — no schema changes */ diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.05.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.05.00.sql new file mode 100644 index 0000000..283e14e --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.05.00.sql @@ -0,0 +1 @@ +ALTER TABLE `#__mokoog_tags` ADD COLUMN `custom_schema` TEXT NULL AFTER `canonical_url`; diff --git a/source/packages/com_mokoog/src/Controller/BatchController.php b/source/packages/com_mokoog/src/Controller/BatchController.php index 68141f4..b747f4f 100644 --- a/source/packages/com_mokoog/src/Controller/BatchController.php +++ b/source/packages/com_mokoog/src/Controller/BatchController.php @@ -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'); } } diff --git a/source/packages/com_mokoog/src/Controller/DisplayController.php b/source/packages/com_mokoog/src/Controller/DisplayController.php index 28b73c9..93330a7 100644 --- a/source/packages/com_mokoog/src/Controller/DisplayController.php +++ b/source/packages/com_mokoog/src/Controller/DisplayController.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/Extension/MokoOGComponent.php b/source/packages/com_mokoog/src/Extension/MokoOGComponent.php index f8e3a9e..5262b25 100644 --- a/source/packages/com_mokoog/src/Extension/MokoOGComponent.php +++ b/source/packages/com_mokoog/src/Extension/MokoOGComponent.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/Model/TagModel.php b/source/packages/com_mokoog/src/Model/TagModel.php index c56b682..04a9c75 100644 --- a/source/packages/com_mokoog/src/Model/TagModel.php +++ b/source/packages/com_mokoog/src/Model/TagModel.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/Model/TagsModel.php b/source/packages/com_mokoog/src/Model/TagsModel.php index 4171c21..81d7f23 100644 --- a/source/packages/com_mokoog/src/Model/TagsModel.php +++ b/source/packages/com_mokoog/src/Model/TagsModel.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/src/View/Tags/HtmlView.php b/source/packages/com_mokoog/src/View/Tags/HtmlView.php index eca4100..9a07a00 100644 --- a/source/packages/com_mokoog/src/View/Tags/HtmlView.php +++ b/source/packages/com_mokoog/src/View/Tags/HtmlView.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/com_mokoog/tmpl/tags/coverage.php b/source/packages/com_mokoog/tmpl/tags/coverage.php new file mode 100644 index 0000000..eb607b3 --- /dev/null +++ b/source/packages/com_mokoog/tmpl/tags/coverage.php @@ -0,0 +1,58 @@ +<?php + +/** + * @package MokoSuiteOpenGraph + * @subpackage com_mokoog + * @author Moko Consulting <hello@mokoconsulting.tech> + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +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> diff --git a/source/packages/com_mokoog/tmpl/tags/default.php b/source/packages/com_mokoog/tmpl/tags/default.php index 6ca4e90..f3a3ad0 100644 --- a/source/packages/com_mokoog/tmpl/tags/default.php +++ b/source/packages/com_mokoog/tmpl/tags/default.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage com_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -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"> diff --git a/source/packages/plg_content_mokoog/forms/mokoog.xml b/source/packages/plg_content_mokoog/forms/mokoog.xml index 64caba3..f34ee65 100644 --- a/source/packages/plg_content_mokoog/forms/mokoog.xml +++ b/source/packages/plg_content_mokoog/forms/mokoog.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -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" @@ -66,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" @@ -75,7 +75,7 @@ description="PLG_CONTENT_MOKOOG_FIELD_META_DESCRIPTION_DESC" filter="string" rows="3" - maxlength="200" + maxlength="255" /> <field name="robots" @@ -101,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> diff --git a/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini index e5859fa..ffa9954 100644 --- a/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini +++ b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini @@ -1,4 +1,4 @@ -; MokoJoomOpenGraph - Content Plugin Language File +; MokoSuiteOpenGraph - Content Plugin Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later @@ -29,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)." diff --git a/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini index 5d16a1b..51e1f35 100644 --- a/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini +++ b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoJoomOpenGraph - Content Plugin System Language File +; MokoSuiteOpenGraph - Content Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_CONTENT_MOKOOG="Content - MokoJoomOpenGraph" +PLG_CONTENT_MOKOOG="Content - MokoSuiteOpenGraph" PLG_CONTENT_MOKOOG_DESCRIPTION="Adds Open Graph fields to article and menu item edit forms for per-page social sharing control." diff --git a/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini index e5859fa..ffa9954 100644 --- a/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini +++ b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini @@ -1,4 +1,4 @@ -; MokoJoomOpenGraph - Content Plugin Language File +; MokoSuiteOpenGraph - Content Plugin Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later @@ -29,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)." diff --git a/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini index 5d16a1b..51e1f35 100644 --- a/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini +++ b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoJoomOpenGraph - Content Plugin System Language File +; MokoSuiteOpenGraph - Content Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_CONTENT_MOKOOG="Content - MokoJoomOpenGraph" +PLG_CONTENT_MOKOOG="Content - MokoSuiteOpenGraph" PLG_CONTENT_MOKOOG_DESCRIPTION="Adds Open Graph fields to article and menu item edit forms for per-page social sharing control." diff --git a/source/packages/plg_content_mokoog/media/css/preview.css b/source/packages/plg_content_mokoog/media/css/preview.css index ae0a8af..6154e26 100644 --- a/source/packages/plg_content_mokoog/media/css/preview.css +++ b/source/packages/plg_content_mokoog/media/css/preview.css @@ -1,5 +1,5 @@ /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_content_mokoog * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GPL-3.0-or-later @@ -125,6 +125,101 @@ 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; @@ -145,3 +240,16 @@ 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; } diff --git a/source/packages/plg_content_mokoog/media/joomla.asset.json b/source/packages/plg_content_mokoog/media/joomla.asset.json index 626a0fc..81340aa 100644 --- a/source/packages/plg_content_mokoog/media/joomla.asset.json +++ b/source/packages/plg_content_mokoog/media/joomla.asset.json @@ -2,7 +2,7 @@ "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json", "name": "plg_content_mokoog", "version": "01.00.00", - "description": "MokoJoomOpenGraph Content Plugin Assets", + "description": "MokoSuiteOpenGraph Content Plugin Assets", "license": "GPL-3.0-or-later", "assets": [ { diff --git a/source/packages/plg_content_mokoog/media/js/preview.js b/source/packages/plg_content_mokoog/media/js/preview.js index 6cc4e1a..46be896 100644 --- a/source/packages/plg_content_mokoog/media/js/preview.js +++ b/source/packages/plg_content_mokoog/media/js/preview.js @@ -1,5 +1,5 @@ /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -53,6 +53,49 @@ document.addEventListener('DOMContentLoaded', function () { 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') || @@ -175,6 +218,107 @@ document.addEventListener('DOMContentLoaded', function () { 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); @@ -229,19 +373,127 @@ document.addEventListener('DOMContentLoaded', function () { } 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(); }); diff --git a/source/packages/plg_content_mokoog/mokoog.php b/source/packages/plg_content_mokoog/mokoog.php index 0f81e14..781f194 100644 --- a/source/packages/plg_content_mokoog/mokoog.php +++ b/source/packages/plg_content_mokoog/mokoog.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/plg_content_mokoog/mokoog.xml b/source/packages/plg_content_mokoog/mokoog.xml index 8e650f0..5843452 100644 --- a/source/packages/plg_content_mokoog/mokoog.xml +++ b/source/packages/plg_content_mokoog/mokoog.xml @@ -1,14 +1,14 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE --> <extension type="plugin" group="content" method="upgrade"> - <name>Content - MokoJoomOpenGraph</name> - <version>01.04.00</version> + <name>Content - MokoSuiteOpenGraph</name> + <version>01.04.17</version> <creationDate>2026-05-23</creationDate> <author>Moko Consulting</author> <authorEmail>hello@mokoconsulting.tech</authorEmail> diff --git a/source/packages/plg_content_mokoog/services/provider.php b/source/packages/plg_content_mokoog/services/provider.php index aca7a7d..be5bb48 100644 --- a/source/packages/plg_content_mokoog/services/provider.php +++ b/source/packages/plg_content_mokoog/services/provider.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php b/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php index 7500169..15f2db0 100644 --- a/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php +++ b/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_content_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -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]); } } } @@ -195,6 +212,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface $query = $db->getQuery(true) ->select($db->quoteName([ '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,15 +263,18 @@ 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'), 'og_video' => $this->sanitizeUrl($ogData['og_video'] ?? ''), - 'seo_title' => trim($ogData['seo_title'] ?? ''), - 'meta_description' => trim($ogData['meta_description'] ?? ''), + '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'] ?? ''), + 'canonical_url' => $this->sanitizeUrl($ogData['canonical_url'] ?? ''), 'published' => 1, 'modified' => Factory::getDate()->toSql(), ]; @@ -267,6 +288,47 @@ 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. * diff --git a/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini index e6fdb6b..7a8f041 100644 --- a/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini +++ b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini @@ -1,4 +1,4 @@ -; MokoJoomOpenGraph - System Plugin Language File +; MokoSuiteOpenGraph - System Plugin Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later @@ -37,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." diff --git a/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini index 2a356e2..5b3afc6 100644 --- a/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini +++ b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoJoomOpenGraph - System Plugin System Language File +; MokoSuiteOpenGraph - System Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_SYSTEM_MOKOOG="System - MokoJoomOpenGraph" +PLG_SYSTEM_MOKOOG="System - MokoSuiteOpenGraph" PLG_SYSTEM_MOKOOG_DESCRIPTION="Injects Open Graph and Twitter Card meta tags into every page for optimal social media sharing previews." diff --git a/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini index e6fdb6b..7a8f041 100644 --- a/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini +++ b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini @@ -1,4 +1,4 @@ -; MokoJoomOpenGraph - System Plugin Language File +; MokoSuiteOpenGraph - System Plugin Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later @@ -37,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." diff --git a/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini index 2a356e2..5b3afc6 100644 --- a/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini +++ b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoJoomOpenGraph - System Plugin System Language File +; MokoSuiteOpenGraph - System Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_SYSTEM_MOKOOG="System - MokoJoomOpenGraph" +PLG_SYSTEM_MOKOOG="System - MokoSuiteOpenGraph" PLG_SYSTEM_MOKOOG_DESCRIPTION="Injects Open Graph and Twitter Card meta tags into every page for optimal social media sharing previews." diff --git a/source/packages/plg_system_mokoog/mokoog.php b/source/packages/plg_system_mokoog/mokoog.php index bfc5577..9e8a445 100644 --- a/source/packages/plg_system_mokoog/mokoog.php +++ b/source/packages/plg_system_mokoog/mokoog.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_system_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/plg_system_mokoog/mokoog.xml b/source/packages/plg_system_mokoog/mokoog.xml index 6794b84..4ce91d7 100644 --- a/source/packages/plg_system_mokoog/mokoog.xml +++ b/source/packages/plg_system_mokoog/mokoog.xml @@ -1,14 +1,14 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoJoomOpenGraph + * @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 --> <extension type="plugin" group="system" method="upgrade"> - <name>System - MokoJoomOpenGraph</name> - <version>01.04.00</version> + <name>System - MokoSuiteOpenGraph</name> + <version>01.04.17</version> <creationDate>2026-05-23</creationDate> <author>Moko Consulting</author> <authorEmail>hello@mokoconsulting.tech</authorEmail> @@ -158,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" @@ -169,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" @@ -181,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> diff --git a/source/packages/plg_system_mokoog/services/provider.php b/source/packages/plg_system_mokoog/services/provider.php index 390b1d3..e398480 100644 --- a/source/packages/plg_system_mokoog/services/provider.php +++ b/source/packages/plg_system_mokoog/services/provider.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_system_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php index e65971f..07c5596 100644 --- a/source/packages/plg_system_mokoog/src/Extension/MokoOG.php +++ b/source/packages/plg_system_mokoog/src/Extension/MokoOG.php @@ -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', ]; } @@ -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) { @@ -282,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(); @@ -290,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)); + } + } } /** @@ -353,6 +447,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface 'og_image' => '', 'og_type' => '', 'og_video' => '', + 'event_data' => '', + 'recipe_data' => '', + 'custom_schema' => '', 'seo_title' => '', 'meta_description' => '', 'robots' => '', @@ -623,6 +720,226 @@ 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); + + if (!SitemapBuilder::writeToFile($xml)) { + \Joomla\CMS\Log\Log::add('MokoOG: Failed to write sitemap.xml — check file permissions', \Joomla\CMS\Log\Log::WARNING, 'mokoog'); + } + } + + /** + * 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; + } + + if (!\Joomla\CMS\Session\Session::checkToken()) { + $event->setArgument('result', ['Invalid Token']); + return; + } + + 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 = mb_substr(strip_tags($input->getString('article_title', '')), 0, 200); + + $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. * diff --git a/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php index 6bdca69..2aaf5e9 100644 --- a/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php +++ b/source/packages/plg_system_mokoog/src/Helper/ImageHelper.php @@ -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. * diff --git a/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php b/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php index 7f0598a..5a84e6e 100644 --- a/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php +++ b/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php @@ -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. * diff --git a/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php b/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php new file mode 100644 index 0000000..af2149c --- /dev/null +++ b/source/packages/plg_system_mokoog/src/Helper/SitemapBuilder.php @@ -0,0 +1,110 @@ +<?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 + { + $allowed = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']; + $changefreq = \in_array($changefreq, $allowed, true) ? $changefreq : 'weekly'; + + $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 = array_map('intval', $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, true)) { + 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); + } +} diff --git a/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini b/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini index 97f99e3..23b9cd1 100644 --- a/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini +++ b/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.ini @@ -1,5 +1,5 @@ -; MokoJoomOpenGraph - Web Services Plugin Language File +; MokoSuiteOpenGraph - Web Services Plugin Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_WEBSERVICES_MOKOOG="Web Services - MokoJoomOpenGraph" +PLG_WEBSERVICES_MOKOOG="Web Services - MokoSuiteOpenGraph" diff --git a/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini b/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini index 086ffe8..8d41317 100644 --- a/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini +++ b/source/packages/plg_webservices_mokoog/language/en-GB/plg_webservices_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoJoomOpenGraph - Web Services Plugin System Language File +; MokoSuiteOpenGraph - Web Services Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_WEBSERVICES_MOKOOG="Web Services - MokoJoomOpenGraph" -PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoJoomOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags." +PLG_WEBSERVICES_MOKOOG="Web Services - MokoSuiteOpenGraph" +PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoSuiteOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags." diff --git a/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini b/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini index 97f99e3..23b9cd1 100644 --- a/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini +++ b/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.ini @@ -1,5 +1,5 @@ -; MokoJoomOpenGraph - Web Services Plugin Language File +; MokoSuiteOpenGraph - Web Services Plugin Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_WEBSERVICES_MOKOOG="Web Services - MokoJoomOpenGraph" +PLG_WEBSERVICES_MOKOOG="Web Services - MokoSuiteOpenGraph" diff --git a/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini b/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini index 086ffe8..8d41317 100644 --- a/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini +++ b/source/packages/plg_webservices_mokoog/language/en-US/plg_webservices_mokoog.sys.ini @@ -1,6 +1,6 @@ -; MokoJoomOpenGraph - Web Services Plugin System Language File +; MokoSuiteOpenGraph - Web Services Plugin System Language File ; Copyright (C) 2026 Moko Consulting. All rights reserved. ; License: GPL-3.0-or-later -PLG_WEBSERVICES_MOKOOG="Web Services - MokoJoomOpenGraph" -PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoJoomOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags." +PLG_WEBSERVICES_MOKOOG="Web Services - MokoSuiteOpenGraph" +PLG_WEBSERVICES_MOKOOG_DESCRIPTION="Exposes MokoSuiteOpenGraph OG tag data via Joomla's REST API at /api/index.php/v1/mokoog/tags." diff --git a/source/packages/plg_webservices_mokoog/mokoog.php b/source/packages/plg_webservices_mokoog/mokoog.php index 945c592..2b3765d 100644 --- a/source/packages/plg_webservices_mokoog/mokoog.php +++ b/source/packages/plg_webservices_mokoog/mokoog.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_webservices_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/plg_webservices_mokoog/mokoog.xml b/source/packages/plg_webservices_mokoog/mokoog.xml index 83e437e..9d907e5 100644 --- a/source/packages/plg_webservices_mokoog/mokoog.xml +++ b/source/packages/plg_webservices_mokoog/mokoog.xml @@ -1,14 +1,14 @@ <?xml version="1.0" encoding="UTF-8"?> <!-- - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_webservices_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE --> <extension type="plugin" group="webservices" method="upgrade"> - <name>Web Services - MokoJoomOpenGraph</name> - <version>01.04.00</version> + <name>Web Services - MokoSuiteOpenGraph</name> + <version>01.04.17</version> <creationDate>2026-05-23</creationDate> <author>Moko Consulting</author> <authorEmail>hello@mokoconsulting.tech</authorEmail> diff --git a/source/packages/plg_webservices_mokoog/services/provider.php b/source/packages/plg_webservices_mokoog/services/provider.php index be36a42..72b352e 100644 --- a/source/packages/plg_webservices_mokoog/services/provider.php +++ b/source/packages/plg_webservices_mokoog/services/provider.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_webservices_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. diff --git a/source/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php b/source/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php index b5cc48f..9fe7a1e 100644 --- a/source/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php +++ b/source/packages/plg_webservices_mokoog/src/Extension/MokoOGWebServices.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @subpackage plg_webservices_mokoog * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. @@ -38,7 +38,7 @@ final class MokoOGWebServices extends CMSPlugin implements SubscriberInterface } /** - * Register API routes for MokoJoomOpenGraph. + * Register API routes for MokoSuiteOpenGraph. * * Endpoints: * GET /api/index.php/v1/mokoog/tags - List all OG tags diff --git a/source/pkg_mokoog.xml b/source/pkg_mokoog.xml index dcb6e2e..93f82b3 100644 --- a/source/pkg_mokoog.xml +++ b/source/pkg_mokoog.xml @@ -8,7 +8,7 @@ <extension type="package" method="upgrade"> <name>Package - MokoSuiteOpenGraph</name> <packagename>mokoog</packagename> - <version>01.04.00</version> + <version>01.04.17</version> <creationDate>2026-05-23</creationDate> <author>Moko Consulting</author> <authorEmail>hello@mokoconsulting.tech</authorEmail> diff --git a/source/script.php b/source/script.php index 315a788..479c916 100644 --- a/source/script.php +++ b/source/script.php @@ -1,7 +1,7 @@ <?php /** - * @package MokoJoomOpenGraph + * @package MokoSuiteOpenGraph * @author Moko Consulting <hello@mokoconsulting.tech> * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -15,8 +15,8 @@ use Joomla\CMS\Language\Text; class Pkg_MokoOGInstallerScript { - protected $minimumJoomla = '4.0.0'; - protected $minimumPhp = '8.1.0'; + protected $minimumJoomla = '6.0.0'; + protected $minimumPhp = '8.2.0'; @@ -33,6 +33,16 @@ class Pkg_MokoOGInstallerScript return false; } + if (version_compare(JVERSION, $this->minimumJoomla, '<')) + { + Factory::getApplication()->enqueueMessage( + Text::sprintf('PKG_MOKOOG_JOOMLA_VERSION_ERROR', $this->minimumJoomla), + 'error' + ); + + return false; + } + $this->saveDownloadKey(); return true; @@ -128,7 +138,7 @@ class Pkg_MokoOGInstallerScript $db->getQuery(true) ->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')]) ->from($db->quoteName('#__update_sites')) - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoJoomOpenGraph%') . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoJoomOpenGraph%') . ')') + ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteOpenGraph%') . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteOpenGraph%') . ')') ->setLimit(1) ); $site = $db->loadObject(); diff --git a/tests/Unit/Helper/JsonLdBuilderTest.php b/tests/Unit/Helper/JsonLdBuilderTest.php new file mode 100644 index 0000000..e7b3921 --- /dev/null +++ b/tests/Unit/Helper/JsonLdBuilderTest.php @@ -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; + } + }; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..36f2c94 --- /dev/null +++ b/tests/bootstrap.php @@ -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';