Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfd0fef3b8 | |||
| 26328d530e | |||
| 77da0c5517 | |||
| 6a928f856f | |||
| fa75b7d9c4 | |||
| 46e30c950b | |||
| 77148d2401 | |||
| 38af92b876 | |||
| 28d44d6884 | |||
| 3d2d91ace5 | |||
| 0cc69b7d77 | |||
| 1375c5820e | |||
| 0e6137b064 | |||
| e105474c68 | |||
| 7fd716f3a4 | |||
| ca06c86328 | |||
| 7a7041c7f3 | |||
| f484675300 | |||
| 8793e6b3f4 | |||
| 0afc8b135a | |||
| 433ecfea71 | |||
| a67cd6da76 |
+1
-1
@@ -113,7 +113,7 @@ releases/
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
site/
|
||||
/site/
|
||||
*.map
|
||||
*.css.map
|
||||
*.js.map
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<display-name>Package - MokoJoomOpenGraph</display-name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Open Graph, SEO meta tags, and social sharing image management for Joomla articles and menu items</description>
|
||||
<version>01.00.03</version>
|
||||
<version>01.00.08</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
+17
-5
@@ -1,8 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
<!-- VERSION: 01.00.03 -->
|
||||
<!-- VERSION: 01.00.08 -->
|
||||
|
||||
All notable changes to MokoJoomOpenGraph will be documented in this file.
|
||||
All notable changes to MokoSuiteOpenGraph will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
@@ -25,9 +25,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- CSV import/export for bulk OG tag management (#12)
|
||||
- OG image text overlay generator (#7)
|
||||
- Multilingual OG tag support with per-language records (#11)
|
||||
- JSON-LD structured data: Article, WebPage, BreadcrumbList schemas (#6)
|
||||
- JSON-LD structured data: Article, Product, WebPage, BreadcrumbList schemas (#6)
|
||||
- Social platform debugger quick links (Facebook, LinkedIn, Google) (#9)
|
||||
- Content type adapter architecture for K2, VirtueMart, HikaShop (#5)
|
||||
- MokoSuiteShop product OG tag support with pricing meta and JSON-LD Product schema (#53)
|
||||
- WhatsApp and Telegram link preview optimization (#10)
|
||||
- Category-level OG tag support (#4)
|
||||
- Batch OG tag generation for existing articles (#1)
|
||||
@@ -40,5 +40,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
|
||||
|
||||
### Changed
|
||||
- Consolidated article DB queries into single cached lookup — 5 queries reduced to 1 (#38)
|
||||
- Dynamic `og:image:width`/`og:image:height` from actual image dimensions instead of hardcoded (#39)
|
||||
- Replace GD `@` error suppression with `Log::add()` warnings (#49)
|
||||
- TagTable::check() validates og_type, field lengths, canonical_url, robots directives (#43)
|
||||
- CSV import/export now includes language column for multilingual support (#52)
|
||||
- Batch process limit capped at 200 per request (#42)
|
||||
- Canonical URL replacement uses public `getHeadData()`/`setHeadData()` API (#39)
|
||||
- Language-aware queries on `loadOgDataByType()` and `loadOgDataByMenu()` (#47)
|
||||
|
||||
### Removed
|
||||
- Removed deploy-manual.yml workflow — using Joomla update server for distribution
|
||||
- Removed dead ContentType adapters (K2, VirtueMart, HikaShop) — not targeting these platforms (#36)
|
||||
- Removed `<updateservers>` from package manifest — managed externally (#44)
|
||||
- Removed deploy-manual.yml workflow
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
# MokoSuiteOpenGraph — Code Assessment Issues
|
||||
|
||||
Generated: 2026-06-06
|
||||
Updated: 2026-06-21
|
||||
Reviewed: Full codebase (all PHP, SQL, XML, JS, CSS, templates)
|
||||
|
||||
---
|
||||
|
||||
## Status Legend
|
||||
|
||||
- FIXED — Verified resolved in codebase
|
||||
- OPEN — Still present, needs work
|
||||
- WONTFIX — Intentional or acceptable as-is
|
||||
|
||||
---
|
||||
|
||||
## Bugs
|
||||
|
||||
### BUG-01: Batch generation offset pagination skips articles — FIXED
|
||||
|
||||
**Severity:** High
|
||||
**File:** `source/packages/com_mokoog/src/Controller/BatchController.php:89`
|
||||
|
||||
The `process()` method now correctly uses `$db->setQuery($query, 0, $limit)` with a comment explaining that processed articles are automatically excluded by the LEFT JOIN filter.
|
||||
|
||||
---
|
||||
|
||||
### BUG-02: License key session flag set before check completes — FIXED
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:543`
|
||||
|
||||
Session flag is now set after the DB query succeeds, inside the try block but after query setup. If the query throws, the catch block runs without the flag being set.
|
||||
|
||||
---
|
||||
|
||||
### BUG-03: Hardcoded og:image dimensions are often wrong — FIXED
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:129-134`
|
||||
|
||||
Now uses `$this->getImageDimensions($image)` which calls `getimagesize()` to detect actual dimensions. Dimension meta tags only emitted when dimensions are successfully detected.
|
||||
|
||||
---
|
||||
|
||||
### BUG-04: `strlen()` vs `mb_strlen()` inconsistency in truncation — FIXED
|
||||
|
||||
**Severity:** Low
|
||||
**Files:** MokoOG.php, BatchController.php, HikaShopAdapter.php, K2Adapter.php
|
||||
|
||||
All instances now consistently use `mb_strlen()` for length checks with `mb_substr()` for truncation.
|
||||
|
||||
---
|
||||
|
||||
### BUG-05: `ImageGenerator::wrapText()` can produce broken output — FIXED
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php:156`
|
||||
|
||||
Now checks `mb_strlen($lines[2]) > 3` before truncating. Short lines get `'...'` appended instead.
|
||||
|
||||
---
|
||||
|
||||
## Potential Issues
|
||||
|
||||
### ISSUE-01: ContentType adapters exist but are never wired up — OPEN
|
||||
|
||||
**Severity:** High (wasted code)
|
||||
**Files:**
|
||||
- `source/packages/com_mokoog/src/ContentType/ContentTypeInterface.php`
|
||||
- `source/packages/com_mokoog/src/ContentType/HikaShopAdapter.php`
|
||||
- `source/packages/com_mokoog/src/ContentType/K2Adapter.php`
|
||||
- `source/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php`
|
||||
|
||||
The system plugin (`MokoOG.php`) still never references or loads these adapters. The `findImage()` and `loadOgData()` methods only handle `com_content`. Third-party content types get no auto-generated OG tags.
|
||||
|
||||
**Action:** Wire adapters into the system plugin's `onBeforeCompileHead` flow, or remove them if not planned for v1.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-02: `applySeoTags()` accesses internal `$doc->_links` property — OPEN
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:257-259`
|
||||
|
||||
Still directly accessing `$doc->_links` (protected/internal property). Fragile across Joomla versions.
|
||||
|
||||
**Fix:** Use `$doc->getHeadData()` to read links and `$doc->addHeadLink()` with proper clearing logic.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-03: No input sanitization on OG values before output — OPEN
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php`
|
||||
|
||||
No `htmlspecialchars()` or `InputFilter` found in the content plugin's save path. While Joomla's `setMetaData()` escapes on output, defense-in-depth recommends sanitizing on input.
|
||||
|
||||
**Fix:** Apply `htmlspecialchars()` or Joomla's `InputFilter` when saving OG data.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-04: `loadOgDataByType()` and `loadOgDataByMenu()` ignore language — OPEN
|
||||
|
||||
**Severity:** Medium
|
||||
**Files:**
|
||||
- `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:324-337` (`loadOgDataByType`)
|
||||
- `source/packages/plg_system_mokoog/src/Extension/MokoOG.php:346-359` (`loadOgDataByMenu`)
|
||||
|
||||
These methods still have no language filter. On multilingual sites, category fallback or menu OG data could come from any language. The unique key is now `(content_type, content_id, language)` but these queries don't filter by language, so `loadObject()` returns an arbitrary match.
|
||||
|
||||
**Fix:** Add the same language filter pattern used in `loadOgData()`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-05: VirtueMart adapter interpolates language into table name — OPEN (low risk)
|
||||
|
||||
**Severity:** Low (defense-in-depth)
|
||||
**File:** `source/packages/com_mokoog/src/ContentType/VirtueMartAdapter.php:34,47`
|
||||
|
||||
Language tag is interpolated into the table name. While `quoteName()` wraps the result, the language tag itself is not validated against an allowlist.
|
||||
|
||||
**Fix:** Validate tag format with a regex before interpolation.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-06: No admin list controller for publish/delete operations — OPEN
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/com_mokoog/src/Controller/`
|
||||
|
||||
No `TagsController extends AdminController` exists. The admin list view toolbar buttons for delete/publish/unpublish will produce task routing errors.
|
||||
|
||||
**Fix:** Add a `TagsController extends AdminController` with proper CSRF and ACL checks.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-07: CSV import/export does not handle `language` column — OPEN
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/com_mokoog/src/Controller/ImportExportController.php`
|
||||
|
||||
No reference to `language` found in the controller. Export omits the column, import creates records with default `*` language. Multilingual sites cannot bulk import/export language-specific OG data.
|
||||
|
||||
**Fix:** Add `language` as a column in export, and parse it on import with a fallback to `*`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-08: No ACL check in content plugin form injection — WONTFIX
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php:49`
|
||||
|
||||
Any user who can edit an article can modify OG tags. This is acceptable behavior for most sites — if you can edit the article, you should be able to control its social sharing appearance.
|
||||
|
||||
---
|
||||
|
||||
## New Issues (Found 2026-06-21)
|
||||
|
||||
### ISSUE-09: ImageGenerator uses @ error suppression on GD functions
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
|
||||
|
||||
All GD library calls use the `@` suppression operator, making debugging difficult. If the GD extension is missing or a font file is not found, failures are completely silent.
|
||||
|
||||
**Fix:** Replace `@` suppression with proper error checking and logging via `Log::add()`.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-10: No TTF font file bundled or documented
|
||||
|
||||
**Severity:** Medium
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
|
||||
|
||||
The image generator requires a TTF font file for text overlay, but no font is included in the package and no fallback or documentation exists for configuring the font path.
|
||||
|
||||
**Fix:** Bundle a permissively-licensed font (e.g., Open Sans, Noto Sans) or document the required configuration.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-11: ImageGenerator cache grows unbounded
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/ImageGenerator.php`
|
||||
|
||||
Generated images in `images/mokoog/generated/` are never cleaned up. On sites with many articles, this directory grows indefinitely.
|
||||
|
||||
**Fix:** Add a cleanup CLI command or admin button (see FEAT-07), or implement LRU/TTL-based cache eviction.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-12: JSON-LD missing common schema types
|
||||
|
||||
**Severity:** Low
|
||||
**File:** `source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php`
|
||||
|
||||
Only 4 schema types are implemented (Article, WebPage, BreadcrumbList, Organization). Missing: NewsArticle, BlogPosting, Product, VideoObject, Event — some of which correspond to existing `og_type` dropdown values.
|
||||
|
||||
**Fix:** Add at least NewsArticle and BlogPosting as Article subtypes.
|
||||
|
||||
---
|
||||
|
||||
### ISSUE-13: No API input validation beyond field whitelisting
|
||||
|
||||
**Severity:** Low
|
||||
**Files:**
|
||||
- `source/packages/com_mokoog/api/src/Controller/TagsController.php`
|
||||
- `source/packages/com_mokoog/api/src/View/Tags/JsonapiView.php`
|
||||
|
||||
The REST API exposes full CRUD but has no validation for field content (e.g., max lengths, valid URLs for og_image/canonical_url, valid og_type values).
|
||||
|
||||
**Fix:** Add validation rules matching the form XML constraints.
|
||||
|
||||
---
|
||||
|
||||
## Feature Expansion Opportunities
|
||||
|
||||
### FEAT-01: Wire up ContentType adapter system — NOT IMPLEMENTED
|
||||
|
||||
Connect the existing `ContentTypeInterface` adapters to the system plugin so HikaShop products, K2 items, and VirtueMart products automatically get OG tags. Blocked by ISSUE-01.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-02: Admin edit view for individual OG tag records — NOT IMPLEMENTED
|
||||
|
||||
A `TagModel` and `tag.xml` form exist but there's no edit template (`tmpl/tag/`) or `TagController`. Users can only manage OG tags through article/menu editors.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-03: Publish/unpublish toggle in admin list — NOT IMPLEMENTED
|
||||
|
||||
Blocked by ISSUE-06 (no TagsController). The list view shows published status as text but has no clickable toggle.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-04: Actual image dimension detection for og:image meta — FIXED
|
||||
|
||||
Implemented via `getImageDimensions()` method using `getimagesize()`. See BUG-03.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-05: Duplicate OG tag detection — NOT IMPLEMENTED
|
||||
|
||||
No detection for conflicting OG meta tags from other extensions.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-06: Support og:video and og:audio URLs — NOT IMPLEMENTED
|
||||
|
||||
No `og_video` or `og_audio` columns, form fields, or rendering logic found anywhere in the codebase.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-07: Generated image cache cleanup — NOT IMPLEMENTED
|
||||
|
||||
No CLI command or admin purge button. See ISSUE-11.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-08: Sitemap integration — NOT IMPLEMENTED
|
||||
|
||||
No sitemap generation or integration exists.
|
||||
|
||||
---
|
||||
|
||||
### FEAT-09: Social share preview in admin list — NOT IMPLEMENTED
|
||||
|
||||
No thumbnails or inline validation in the admin list view. Live preview only exists in the article/menu editor (via plg_content_mokoog).
|
||||
|
||||
---
|
||||
|
||||
### FEAT-10: Bulk OG tag editing — NOT IMPLEMENTED
|
||||
|
||||
No batch edit modal for selecting multiple items and changing common fields.
|
||||
|
||||
---
|
||||
|
||||
## Security Fixes (from CHANGELOG [Unreleased])
|
||||
|
||||
All 4 claimed security fixes have been **verified as implemented**:
|
||||
|
||||
| Fix | Status | Evidence |
|
||||
|-----|--------|----------|
|
||||
| JSON-LD XSS (#34) | IMPLEMENTED | `</` escaping in `JsonLdBuilder::toScriptTag()` |
|
||||
| ACL on Batch/ImportExport (#37) | IMPLEMENTED | `authorise()` checks on all controller methods |
|
||||
| CSV import validation (#35) | IMPLEMENTED | File type, MIME, size (2MB), content_type regex |
|
||||
| Multilingual data corruption (#41) | IMPLEMENTED | Language-aware load/save in content plugin |
|
||||
|
||||
Additional security review found **no vulnerabilities** for: SQL injection, CSRF, file upload, path traversal, code injection, or XSS in output.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Total | Fixed | Open | Won't Fix |
|
||||
|----------|-------|-------|------|-----------|
|
||||
| Bugs | 5 | 5 | 0 | 0 |
|
||||
| Issues | 13 | 0 | 12 | 1 |
|
||||
| Features | 10 | 1 | 9 | 0 |
|
||||
| Security | 4 | 4 | 0 | 0 |
|
||||
|
||||
### Priority for v1.0.0 Release
|
||||
|
||||
**Must fix:**
|
||||
- ISSUE-06: TagsController for admin list operations (publish/delete broken)
|
||||
- ISSUE-04: Language filter on loadOgDataByType/loadOgDataByMenu (data integrity on multilingual sites)
|
||||
|
||||
**Should fix:**
|
||||
- ISSUE-02: Replace `$doc->_links` access (Joomla version fragility)
|
||||
- ISSUE-03: Input sanitization on save (defense-in-depth)
|
||||
- ISSUE-09: GD error suppression (debuggability)
|
||||
- ISSUE-10: Bundle or document TTF font requirement
|
||||
|
||||
**Nice to have for v1.0.0:**
|
||||
- FEAT-02: Admin edit view
|
||||
- FEAT-03: Publish/unpublish toggle
|
||||
- ISSUE-07: Language column in CSV import/export
|
||||
@@ -1,12 +1,12 @@
|
||||
# MokoJoomOpenGraph
|
||||
# MokoSuiteOpenGraph
|
||||
|
||||
<!-- VERSION: 01.00.03 -->
|
||||
<!-- VERSION: 01.00.08 -->
|
||||
|
||||
Open Graph, Twitter Card, and social sharing meta tag management for Joomla 4/5/6.
|
||||
|
||||
## Overview
|
||||
|
||||
MokoJoomOpenGraph gives you full control over how your Joomla content appears when shared on Facebook, Twitter/X, LinkedIn, Discord, WhatsApp, Telegram, and other social platforms. Set custom titles, descriptions, and images per article, menu item, and category — or let the extension auto-generate them from your existing content.
|
||||
MokoSuiteOpenGraph gives you full control over how your Joomla content appears when shared on Facebook, Twitter/X, LinkedIn, Discord, WhatsApp, Telegram, and other social platforms. Set custom titles, descriptions, and images per article, menu item, and category — or let the extension auto-generate them from your existing content.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -31,7 +31,7 @@ MokoJoomOpenGraph gives you full control over how your Joomla content appears wh
|
||||
- **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, WebPage, BreadcrumbList, Organization schemas
|
||||
- **JSON-LD structured data** — Article, Product, WebPage, BreadcrumbList, Organization schemas
|
||||
|
||||
### Admin Tools
|
||||
- **Tag manager dashboard** — View and manage all OG records centrally
|
||||
@@ -43,19 +43,19 @@ MokoJoomOpenGraph gives you full control over how your Joomla content appears wh
|
||||
|
||||
### Developer Features
|
||||
- **REST API** — Full CRUD via Joomla Web Services (`/api/v1/mokoog/tags`)
|
||||
- **Content type adapters** — Extensible architecture for K2, VirtueMart, HikaShop
|
||||
- **MokoSuiteShop integration** — Auto-generated OG/JSON-LD for product pages with pricing meta
|
||||
- **Plugin event** — `onMokoOGAfterRender` for third-party plugins to add custom social tags
|
||||
- **OG image generator** — Text overlay on template backgrounds with auto-resize to 1200x630
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/releases)
|
||||
1. Download the latest `pkg_mokoog-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteOpenGraph/releases)
|
||||
2. In Joomla Administrator → Extensions → Install → Upload Package File
|
||||
3. All plugins are enabled automatically on install
|
||||
|
||||
## Configuration
|
||||
|
||||
Navigate to **Extensions → Plugins → System - MokoJoomOpenGraph** to configure:
|
||||
Navigate to **Extensions → Plugins → System - MokoSuiteOpenGraph** to configure:
|
||||
- Site name override
|
||||
- Default OG title and description (site-wide fallback)
|
||||
- Default fallback image
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokoog</name>
|
||||
<version>01.00.03-dev</version>
|
||||
<version>01.00.08-dev</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\ContentType;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
interface ContentTypeInterface
|
||||
{
|
||||
/**
|
||||
* Check if this adapter can handle the given component/view.
|
||||
*
|
||||
* @param string $option Component option (e.g. com_virtuemart)
|
||||
* @param string $view View name (e.g. productdetails)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function canHandle(string $option, string $view): bool;
|
||||
|
||||
/**
|
||||
* Get the content type identifier for database storage.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getContentType(): string;
|
||||
|
||||
/**
|
||||
* Get the title for the content item.
|
||||
*
|
||||
* @param int $id Content item ID
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTitle(int $id): string;
|
||||
|
||||
/**
|
||||
* Get a description for the content item.
|
||||
*
|
||||
* @param int $id Content item ID
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getDescription(int $id): string;
|
||||
|
||||
/**
|
||||
* Get the primary image for the content item.
|
||||
*
|
||||
* @param int $id Content item ID
|
||||
*
|
||||
* @return string Image path relative to JPATH_ROOT, or empty string
|
||||
*/
|
||||
public function getImage(int $id): string;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\ContentType;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class HikaShopAdapter implements ContentTypeInterface
|
||||
{
|
||||
public function canHandle(string $option, string $view): bool
|
||||
{
|
||||
return $option === 'com_hikashop' && $view === 'product';
|
||||
}
|
||||
|
||||
public function getContentType(): string
|
||||
{
|
||||
return 'com_hikashop';
|
||||
}
|
||||
|
||||
public function getTitle(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('product_name'))
|
||||
->from($db->quoteName('#__hikashop_product'))
|
||||
->where($db->quoteName('product_id') . ' = ' . $id);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
public function getDescription(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('product_description'))
|
||||
->from($db->quoteName('#__hikashop_product'))
|
||||
->where($db->quoteName('product_id') . ' = ' . $id);
|
||||
|
||||
$db->setQuery($query);
|
||||
$text = $db->loadResult() ?: '';
|
||||
$text = strip_tags($text);
|
||||
$text = trim(preg_replace('/\s+/', ' ', $text));
|
||||
|
||||
if (mb_strlen($text) > 160) {
|
||||
$text = mb_substr($text, 0, 157) . '...';
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function getImage(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('f.file_path'))
|
||||
->from($db->quoteName('#__hikashop_file', 'f'))
|
||||
->where($db->quoteName('f.file_ref_id') . ' = ' . $id)
|
||||
->where($db->quoteName('f.file_type') . ' = ' . $db->quote('product'))
|
||||
->order($db->quoteName('f.file_ordering') . ' ASC');
|
||||
|
||||
$db->setQuery($query, 0, 1);
|
||||
|
||||
return $db->loadResult() ?: '';
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\ContentType;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class K2Adapter implements ContentTypeInterface
|
||||
{
|
||||
public function canHandle(string $option, string $view): bool
|
||||
{
|
||||
return $option === 'com_k2' && $view === 'item';
|
||||
}
|
||||
|
||||
public function getContentType(): string
|
||||
{
|
||||
return 'com_k2';
|
||||
}
|
||||
|
||||
public function getTitle(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('title'))
|
||||
->from($db->quoteName('#__k2_items'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
public function getDescription(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('introtext'))
|
||||
->from($db->quoteName('#__k2_items'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
|
||||
$db->setQuery($query);
|
||||
$text = $db->loadResult() ?: '';
|
||||
$text = strip_tags($text);
|
||||
$text = trim(preg_replace('/\s+/', ' ', $text));
|
||||
|
||||
if (mb_strlen($text) > 160) {
|
||||
$text = mb_substr($text, 0, 157) . '...';
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function getImage(int $id): string
|
||||
{
|
||||
// K2 stores images as media/k2/items/cache/{md5}_L.jpg
|
||||
$imagePath = 'media/k2/items/cache/' . md5('Image' . $id) . '_L.jpg';
|
||||
|
||||
if (is_file(JPATH_ROOT . '/' . $imagePath)) {
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\ContentType;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class VirtueMartAdapter implements ContentTypeInterface
|
||||
{
|
||||
public function canHandle(string $option, string $view): bool
|
||||
{
|
||||
return $option === 'com_virtuemart' && $view === 'productdetails';
|
||||
}
|
||||
|
||||
public function getContentType(): string
|
||||
{
|
||||
return 'com_virtuemart';
|
||||
}
|
||||
|
||||
public function getTitle(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('product_name'))
|
||||
->from($db->quoteName('#__virtuemart_products_' . $this->getLangTag()))
|
||||
->where($db->quoteName('virtuemart_product_id') . ' = ' . $id);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
public function getDescription(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('product_s_desc'))
|
||||
->from($db->quoteName('#__virtuemart_products_' . $this->getLangTag()))
|
||||
->where($db->quoteName('virtuemart_product_id') . ' = ' . $id);
|
||||
|
||||
$db->setQuery($query);
|
||||
$desc = $db->loadResult() ?: '';
|
||||
|
||||
return strip_tags($desc);
|
||||
}
|
||||
|
||||
public function getImage(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('m.file_url'))
|
||||
->from($db->quoteName('#__virtuemart_product_medias', 'pm'))
|
||||
->join('INNER', $db->quoteName('#__virtuemart_medias', 'm') . ' ON ' . $db->quoteName('m.virtuemart_media_id') . ' = ' . $db->quoteName('pm.virtuemart_media_id'))
|
||||
->where($db->quoteName('pm.virtuemart_product_id') . ' = ' . $id)
|
||||
->order($db->quoteName('pm.ordering') . ' ASC');
|
||||
|
||||
$db->setQuery($query, 0, 1);
|
||||
|
||||
return $db->loadResult() ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VirtueMart language table suffix.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function getLangTag(): string
|
||||
{
|
||||
$lang = Factory::getLanguage()->getTag();
|
||||
|
||||
return strtolower(str_replace('-', '_', $lang));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -67,7 +67,7 @@ class BatchController extends BaseController
|
||||
}
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$limit = $app->getInput()->getInt('limit', 50);
|
||||
$limit = min($app->getInput()->getInt('limit', 50), 200);
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
@@ -90,6 +90,7 @@ class BatchController extends BaseController
|
||||
$articles = $db->loadObjectList();
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$now = Factory::getDate()->toSql();
|
||||
|
||||
foreach ($articles as $article) {
|
||||
@@ -98,23 +99,28 @@ class BatchController extends BaseController
|
||||
$ogImage = $this->extractImage($article);
|
||||
|
||||
$record = (object) [
|
||||
'content_type' => 'com_content',
|
||||
'content_id' => (int) $article->id,
|
||||
'og_title' => $ogTitle,
|
||||
'og_description' => $ogDescription,
|
||||
'og_image' => $ogImage,
|
||||
'og_type' => 'article',
|
||||
'seo_title' => '',
|
||||
'content_type' => 'com_content',
|
||||
'content_id' => (int) $article->id,
|
||||
'og_title' => $ogTitle,
|
||||
'og_description' => $ogDescription,
|
||||
'og_image' => $ogImage,
|
||||
'og_type' => 'article',
|
||||
'seo_title' => '',
|
||||
'meta_description' => $article->metadesc ?: '',
|
||||
'robots' => '',
|
||||
'canonical_url' => '',
|
||||
'published' => 1,
|
||||
'created' => $now,
|
||||
'modified' => $now,
|
||||
'robots' => '',
|
||||
'canonical_url' => '',
|
||||
'language' => '*',
|
||||
'published' => 1,
|
||||
'created' => $now,
|
||||
'modified' => $now,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokoog_tags', $record);
|
||||
$created++;
|
||||
try {
|
||||
$db->insertObject('#__mokoog_tags', $record);
|
||||
$created++;
|
||||
} catch (\RuntimeException $e) {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
echo new JsonResponse([
|
||||
|
||||
@@ -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.
|
||||
@@ -59,6 +59,7 @@ class ImportExportController extends BaseController
|
||||
$db->quoteName('t.meta_description'),
|
||||
$db->quoteName('t.robots'),
|
||||
$db->quoteName('t.canonical_url'),
|
||||
$db->quoteName('t.language'),
|
||||
])
|
||||
->from($db->quoteName('#__mokoog_tags', 't'))
|
||||
->leftJoin(
|
||||
@@ -83,6 +84,7 @@ class ImportExportController extends BaseController
|
||||
'content_type', 'content_id', 'article_title',
|
||||
'og_title', 'og_description', 'og_image', 'og_type',
|
||||
'seo_title', 'meta_description', 'robots', 'canonical_url',
|
||||
'language',
|
||||
]);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
@@ -184,6 +186,12 @@ class ImportExportController extends BaseController
|
||||
$metaDesc = trim($row[8] ?? '');
|
||||
$robots = trim($row[9] ?? '');
|
||||
$canonicalUrl = trim($row[10] ?? '');
|
||||
$language = trim($row[11] ?? '*');
|
||||
|
||||
// Validate language tag format (e.g., 'en-GB', '*')
|
||||
if ($language !== '*' && !preg_match('/^[a-z]{2,3}-[A-Z]{2}$/', $language)) {
|
||||
$language = '*';
|
||||
}
|
||||
|
||||
if (empty($contentType) || $contentId <= 0) {
|
||||
$skipped++;
|
||||
@@ -198,12 +206,13 @@ class ImportExportController extends BaseController
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for existing record
|
||||
// Check for existing record (unique key includes language)
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
||||
->where($db->quoteName('content_id') . ' = ' . $contentId);
|
||||
->where($db->quoteName('content_id') . ' = ' . $contentId)
|
||||
->where($db->quoteName('language') . ' = ' . $db->quote($language));
|
||||
|
||||
$db->setQuery($query);
|
||||
$existingId = $db->loadResult();
|
||||
@@ -219,6 +228,7 @@ class ImportExportController extends BaseController
|
||||
'meta_description' => $metaDesc,
|
||||
'robots' => $robots,
|
||||
'canonical_url' => $canonicalUrl,
|
||||
'language' => $language,
|
||||
'published' => 1,
|
||||
'modified' => $now,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage com_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoOG\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\AdminController;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
|
||||
class TagsController extends AdminController
|
||||
{
|
||||
/**
|
||||
* Proxy for getModel.
|
||||
*
|
||||
* @param string $name Model name
|
||||
* @param string $prefix Model prefix
|
||||
* @param array $config Configuration array
|
||||
*
|
||||
* @return BaseDatabaseModel
|
||||
*/
|
||||
public function getModel($name = 'Tag', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
||||
{
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -32,6 +32,16 @@ class TagTable extends Table
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private const VALID_OG_TYPES = [
|
||||
'article', 'website', 'product', 'profile', 'book', 'music.song',
|
||||
'music.album', 'video.movie', 'video.episode', 'video.other',
|
||||
];
|
||||
|
||||
private const VALID_ROBOTS = [
|
||||
'index', 'noindex', 'follow', 'nofollow', 'none', 'noarchive',
|
||||
'nosnippet', 'noimageindex', 'max-snippet', 'max-image-preview',
|
||||
];
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->content_type)) {
|
||||
@@ -40,12 +50,58 @@ class TagTable extends Table
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-z][a-z0-9_.]*$/', $this->content_type)) {
|
||||
$this->setError('Content type contains invalid characters.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->content_id)) {
|
||||
$this->setError('Content ID is required.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate og_type against known values
|
||||
if (!empty($this->og_type) && !\in_array($this->og_type, self::VALID_OG_TYPES, true)) {
|
||||
$this->og_type = 'article';
|
||||
}
|
||||
|
||||
// Truncate fields to schema max lengths
|
||||
if (mb_strlen($this->og_title ?? '') > 255) {
|
||||
$this->og_title = mb_substr($this->og_title, 0, 255);
|
||||
}
|
||||
|
||||
if (mb_strlen($this->seo_title ?? '') > 70) {
|
||||
$this->seo_title = mb_substr($this->seo_title, 0, 70);
|
||||
}
|
||||
|
||||
if (mb_strlen($this->meta_description ?? '') > 200) {
|
||||
$this->meta_description = mb_substr($this->meta_description, 0, 200);
|
||||
}
|
||||
|
||||
// Validate canonical_url format if non-empty
|
||||
if (!empty($this->canonical_url) && !filter_var($this->canonical_url, FILTER_VALIDATE_URL)) {
|
||||
$this->canonical_url = '';
|
||||
}
|
||||
|
||||
// Validate robots directives
|
||||
if (!empty($this->robots)) {
|
||||
$parts = array_map('trim', explode(',', strtolower($this->robots)));
|
||||
$valid = array_filter($parts, function ($part) {
|
||||
// Allow directives with values like "max-snippet:-1"
|
||||
$directive = explode(':', $part)[0];
|
||||
|
||||
return \in_array($directive, self::VALID_ROBOTS, true);
|
||||
});
|
||||
$this->robots = $valid ? implode(', ', $valid) : '';
|
||||
}
|
||||
|
||||
// Default language to '*' if not set
|
||||
if (empty($this->language)) {
|
||||
$this->language = '*';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoJoomOpenGraph</name>
|
||||
<version>01.00.03-dev</version>
|
||||
<version>01.00.08-dev</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoJoomOpenGraph</name>
|
||||
<version>01.00.03-dev</version>
|
||||
<version>01.00.08-dev</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -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.
|
||||
@@ -112,7 +112,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
$image = $ogData->og_image ?: $this->findImage($option, $view, $id);
|
||||
$url = Uri::getInstance()->toString();
|
||||
$siteName = $this->params->get('og_site_name', $app->get('sitename', ''));
|
||||
$type = $ogData->og_type ?: 'article';
|
||||
$defaultType = ($option === 'com_mokoshop' && $view === 'product') ? 'product' : 'article';
|
||||
$type = $ogData->og_type ?: $defaultType;
|
||||
|
||||
// Open Graph tags
|
||||
$doc->setMetaData('og:title', $title, 'property');
|
||||
@@ -188,6 +189,16 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
// MokoSuiteShop product meta tags
|
||||
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
|
||||
$productData = $this->loadShopProduct($id);
|
||||
|
||||
if ($productData) {
|
||||
$doc->setMetaData('product:price:amount', number_format((float) $productData->price, 2, '.', ''), 'property');
|
||||
$doc->setMetaData('product:price:currency', $productData->currency ?: 'USD', 'property');
|
||||
}
|
||||
}
|
||||
|
||||
// Fire event so third-party plugins can add custom OG/social tags
|
||||
$eventData = [
|
||||
'subject' => $doc,
|
||||
@@ -206,8 +217,10 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
if ($this->params->get('jsonld_enabled', 1)) {
|
||||
$imageUrl = $image ? $this->resolveImageUrl($image) : '';
|
||||
|
||||
if ($option === 'com_content' && $view === 'article' && $id > 0) {
|
||||
$schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl);
|
||||
if ($option === 'com_mokoshop' && $view === 'product' && $id > 0) {
|
||||
$schema = JsonLdBuilder::buildProduct($id, $title, $description, $imageUrl, $this->loadShopProduct($id));
|
||||
} elseif ($option === 'com_content' && $view === 'article' && $id > 0) {
|
||||
$schema = JsonLdBuilder::buildArticle($id, $title, $description, $imageUrl, $this->loadArticle($id));
|
||||
} else {
|
||||
$schema = JsonLdBuilder::buildWebPage($title, $description);
|
||||
}
|
||||
@@ -253,11 +266,17 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
|
||||
// Canonical URL
|
||||
if (!empty($ogData->canonical_url)) {
|
||||
// Remove any existing canonical link first
|
||||
foreach ($doc->_links as $link => $attribs) {
|
||||
if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') {
|
||||
unset($doc->_links[$link]);
|
||||
// Remove any existing canonical link via public API
|
||||
$headData = $doc->getHeadData();
|
||||
|
||||
if (!empty($headData['links'])) {
|
||||
foreach ($headData['links'] as $link => $attribs) {
|
||||
if (isset($attribs['relation']) && $attribs['relation'] === 'canonical') {
|
||||
unset($headData['links'][$link]);
|
||||
}
|
||||
}
|
||||
|
||||
$doc->setHeadData($headData);
|
||||
}
|
||||
|
||||
$doc->addHeadLink($ogData->canonical_url, 'canonical');
|
||||
@@ -323,15 +342,20 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
*/
|
||||
private function loadOgDataByType(string $contentType, int $contentId): ?object
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db = Factory::getDbo();
|
||||
$lang = Factory::getLanguage()->getTag();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote($contentType))
|
||||
->where($db->quoteName('content_id') . ' = ' . $contentId)
|
||||
->where($db->quoteName('published') . ' = 1');
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang)
|
||||
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
|
||||
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->setQuery($query, 0, 1);
|
||||
|
||||
return $db->loadObject();
|
||||
}
|
||||
@@ -345,15 +369,20 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
*/
|
||||
private function loadOgDataByMenu(int $menuId): ?object
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$db = Factory::getDbo();
|
||||
$lang = Factory::getLanguage()->getTag();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote('menu'))
|
||||
->where($db->quoteName('content_id') . ' = ' . $menuId)
|
||||
->where($db->quoteName('published') . ' = 1');
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where('(' . $db->quoteName('language') . ' = ' . $db->quote($lang)
|
||||
. ' OR ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ')')
|
||||
->order('CASE WHEN ' . $db->quoteName('language') . ' = ' . $db->quote('*') . ' THEN 1 ELSE 0 END ASC');
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->setQuery($query, 0, 1);
|
||||
|
||||
return $db->loadObject();
|
||||
}
|
||||
@@ -398,19 +427,31 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
return $this->params->get('default_image', '');
|
||||
}
|
||||
|
||||
// For MokoSuiteShop products, look at the linked article's images
|
||||
if ($option === 'com_mokoshop' && $id > 0) {
|
||||
$productData = $this->loadShopProduct($id);
|
||||
|
||||
if ($productData && !empty($productData->images)) {
|
||||
$imagesData = json_decode($productData->images, true);
|
||||
|
||||
if (!empty($imagesData['image_fulltext'])) {
|
||||
return $imagesData['image_fulltext'];
|
||||
}
|
||||
|
||||
if (!empty($imagesData['image_intro'])) {
|
||||
return $imagesData['image_intro'];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->params->get('default_image', '');
|
||||
}
|
||||
|
||||
// For Joomla articles, look at the intro/full image fields
|
||||
if ($option === 'com_content' && $id > 0) {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('images'))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||
$article = $this->loadArticle($id);
|
||||
|
||||
$db->setQuery($query);
|
||||
$images = $db->loadResult();
|
||||
|
||||
if ($images) {
|
||||
$imagesData = json_decode($images, true);
|
||||
if ($article && !empty($article->images)) {
|
||||
$imagesData = json_decode($article->images, true);
|
||||
|
||||
if (!empty($imagesData['image_fulltext'])) {
|
||||
return $imagesData['image_fulltext'];
|
||||
@@ -423,6 +464,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
|
||||
// Fallback: check the article's category for an image
|
||||
if ($view === 'article') {
|
||||
$db = Factory::getDbo();
|
||||
$catQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('cat.params'))
|
||||
->from($db->quoteName('#__categories', 'cat'))
|
||||
@@ -466,6 +508,38 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
return rtrim(Uri::root(), '/') . '/' . ltrim($image, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and cache a full article record with author for the current request.
|
||||
*
|
||||
* @param int $id Article ID
|
||||
*
|
||||
* @return object|null
|
||||
*/
|
||||
private function loadArticle(int $id): ?object
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (isset($cache[$id])) {
|
||||
return $cache[$id];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName([
|
||||
'a.title', 'a.introtext', 'a.fulltext', 'a.images',
|
||||
'a.created', 'a.modified', 'a.publish_up', 'a.metadesc',
|
||||
]))
|
||||
->select($db->quoteName('u.name', 'author_name'))
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
|
||||
->where($db->quoteName('a.id') . ' = ' . $id);
|
||||
|
||||
$db->setQuery($query);
|
||||
$cache[$id] = $db->loadObject();
|
||||
|
||||
return $cache[$id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a date field from an article.
|
||||
*
|
||||
@@ -476,16 +550,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
*/
|
||||
private function getArticleDate(int $id, string $field): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName($field))
|
||||
->from($db->quoteName('#__content'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$article = $this->loadArticle($id);
|
||||
|
||||
$db->setQuery($query);
|
||||
$date = $db->loadResult();
|
||||
|
||||
return $date ?: '';
|
||||
return $article->$field ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -497,16 +564,9 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
*/
|
||||
private function getArticleAuthor(int $id): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('u.name'))
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
|
||||
->where($db->quoteName('a.id') . ' = ' . $id);
|
||||
$article = $this->loadArticle($id);
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadResult() ?: '';
|
||||
return $article->author_name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -534,7 +594,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('extra_query'))
|
||||
->from($db->quoteName('#__update_sites'))
|
||||
->where($db->quoteName('name') . ' = ' . $db->quote('MokoJoomOpenGraph Updates'))
|
||||
->where($db->quoteName('name') . ' = ' . $db->quote('MokoSuiteOpenGraph Updates'))
|
||||
->setLimit(1);
|
||||
$db->setQuery($query);
|
||||
$extraQuery = (string) $db->loadResult();
|
||||
@@ -555,18 +615,52 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
. 'No download key is configured. Updates will not be available until a valid license key is entered. '
|
||||
. 'Go to <a href="index.php?option=com_installer&view=updatesites">System → Update Sites</a> '
|
||||
. 'and enter your license key (<code>MOKO-XXXX-XXXX-XXXX-XXXX</code>) in the Download Key field '
|
||||
. 'for the MokoJoomOpenGraph update site.',
|
||||
. 'for the MokoSuiteOpenGraph update site.',
|
||||
'warning'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Don't break admin over a license check
|
||||
// Don't break admin over a license check, but log for debugging
|
||||
\Joomla\CMS\Log\Log::add('MokoOG license check: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual pixel dimensions of a local image.
|
||||
* Load MokoSuiteShop product data by product ID.
|
||||
*
|
||||
* Returns [width, height] or null for external URLs or unreadable images.
|
||||
* @param int $productId CRM product ID
|
||||
*
|
||||
* @return object|null Product with name, description, images, price, currency, sku
|
||||
*/
|
||||
private function loadShopProduct(int $productId): ?object
|
||||
{
|
||||
static $cache = [];
|
||||
|
||||
if (isset($cache[$productId])) {
|
||||
return $cache[$productId];
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('p.id, p.sku, p.price, p.currency, p.stock_qty')
|
||||
->select('c.title AS name, c.introtext AS description, c.images')
|
||||
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
|
||||
->join('LEFT', $db->quoteName('#__content', 'c') . ' ON c.id = p.article_id')
|
||||
->where($db->quoteName('p.id') . ' = ' . $productId)
|
||||
->where($db->quoteName('p.published') . ' = 1');
|
||||
|
||||
$db->setQuery($query);
|
||||
$cache[$productId] = $db->loadObject();
|
||||
} catch (\RuntimeException $e) {
|
||||
// MokoSuiteShop tables may not exist
|
||||
$cache[$productId] = null;
|
||||
}
|
||||
|
||||
return $cache[$productId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual pixel dimensions of a local image.
|
||||
*
|
||||
* @param string $image Image path (relative or absolute URL)
|
||||
*
|
||||
@@ -592,7 +686,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
$info = @getimagesize($absPath);
|
||||
$info = getimagesize($absPath);
|
||||
|
||||
if (!$info) {
|
||||
return null;
|
||||
|
||||
@@ -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.
|
||||
@@ -13,6 +13,7 @@ namespace Joomla\Plugin\System\MokoOG\Helper;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Filesystem\Folder;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
class ImageGenerator
|
||||
{
|
||||
@@ -40,20 +41,32 @@ class ImageGenerator
|
||||
array $fontColor = [255, 255, 255],
|
||||
int $quality = 90
|
||||
): string {
|
||||
if (!\extension_loaded('gd')) {
|
||||
Log::add('MokoOG ImageGenerator: GD extension is not loaded. Image generation disabled.', Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$templateAbs = JPATH_ROOT . '/' . ltrim($templateImage, '/');
|
||||
|
||||
if (!is_file($templateAbs)) {
|
||||
Log::add('MokoOG ImageGenerator: Template image not found: ' . $templateImage, Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$fontFile || !is_file($fontFile)) {
|
||||
Log::add('MokoOG ImageGenerator: TTF font file not found: ' . ($fontFile ?: '(not configured)'), Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
|
||||
|
||||
if (!is_dir($outputDir)) {
|
||||
Folder::create($outputDir);
|
||||
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
|
||||
Log::add('MokoOG ImageGenerator: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$hash = md5($title . $templateImage . $fontSize);
|
||||
@@ -67,20 +80,24 @@ class ImageGenerator
|
||||
}
|
||||
|
||||
// Load template image
|
||||
$imageInfo = @getimagesize($templateAbs);
|
||||
$imageInfo = getimagesize($templateAbs);
|
||||
|
||||
if (!$imageInfo) {
|
||||
Log::add('MokoOG ImageGenerator: Cannot read image dimensions: ' . $templateImage, Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$source = match ($imageInfo[2]) {
|
||||
IMAGETYPE_JPEG => @imagecreatefromjpeg($templateAbs),
|
||||
IMAGETYPE_PNG => @imagecreatefrompng($templateAbs),
|
||||
IMAGETYPE_WEBP => @imagecreatefromwebp($templateAbs),
|
||||
IMAGETYPE_JPEG => imagecreatefromjpeg($templateAbs),
|
||||
IMAGETYPE_PNG => imagecreatefrompng($templateAbs),
|
||||
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($templateAbs) : false,
|
||||
default => false,
|
||||
};
|
||||
|
||||
if (!$source) {
|
||||
Log::add('MokoOG ImageGenerator: Failed to load image (unsupported type or corrupt): ' . $templateImage, Log::WARNING, 'mokoog');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -14,6 +14,7 @@ defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Filesystem\File;
|
||||
use Joomla\CMS\Filesystem\Folder;
|
||||
use Joomla\CMS\Log\Log;
|
||||
|
||||
class ImageHelper
|
||||
{
|
||||
@@ -63,9 +64,11 @@ class ImageHelper
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
$imageInfo = @getimagesize($absPath);
|
||||
$imageInfo = getimagesize($absPath);
|
||||
|
||||
if (!$imageInfo) {
|
||||
Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog');
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
@@ -79,8 +82,10 @@ class ImageHelper
|
||||
// Ensure output directory exists
|
||||
$outputDir = JPATH_ROOT . '/' . self::OUTPUT_DIR;
|
||||
|
||||
if (!is_dir($outputDir)) {
|
||||
Folder::create($outputDir);
|
||||
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
|
||||
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . self::OUTPUT_DIR, Log::WARNING, 'mokoog');
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
// Generate output filename based on source hash + dimensions
|
||||
@@ -179,7 +184,7 @@ class ImageHelper
|
||||
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'File not found'];
|
||||
}
|
||||
|
||||
$imageInfo = @getimagesize($absPath);
|
||||
$imageInfo = getimagesize($absPath);
|
||||
|
||||
if (!$imageInfo) {
|
||||
return ['valid' => false, 'width' => 0, 'height' => 0, 'message' => 'Not a valid image'];
|
||||
@@ -211,12 +216,18 @@ class ImageHelper
|
||||
*/
|
||||
private static function loadImage(string $path, int $type)
|
||||
{
|
||||
return match ($type) {
|
||||
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
|
||||
IMAGETYPE_PNG => @imagecreatefrompng($path),
|
||||
IMAGETYPE_GIF => @imagecreatefromgif($path),
|
||||
IMAGETYPE_WEBP => @imagecreatefromwebp($path),
|
||||
$image = match ($type) {
|
||||
IMAGETYPE_JPEG => imagecreatefromjpeg($path),
|
||||
IMAGETYPE_PNG => imagecreatefrompng($path),
|
||||
IMAGETYPE_GIF => imagecreatefromgif($path),
|
||||
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($path) : false,
|
||||
default => false,
|
||||
};
|
||||
|
||||
if (!$image) {
|
||||
Log::add('MokoOG ImageHelper: Failed to load image: ' . basename($path), Log::WARNING, 'mokoog');
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -20,31 +20,36 @@ class JsonLdBuilder
|
||||
/**
|
||||
* Build Article schema for a com_content article.
|
||||
*
|
||||
* @param int $articleId Article ID
|
||||
* @param string $title Page title
|
||||
* @param string $description Page description
|
||||
* @param string $image Image URL (absolute)
|
||||
* @param int $articleId Article ID
|
||||
* @param string $title Page title
|
||||
* @param string $description Page description
|
||||
* @param string $image Image URL (absolute)
|
||||
* @param object|null $cachedArticle Pre-loaded article data (avoids duplicate query)
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildArticle(int $articleId, string $title, string $description, string $image): ?array
|
||||
public static function buildArticle(int $articleId, string $title, string $description, string $image, ?object $cachedArticle = null): ?array
|
||||
{
|
||||
if ($articleId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName([
|
||||
'a.created', 'a.modified', 'a.publish_up',
|
||||
'u.name',
|
||||
]))
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
|
||||
->where($db->quoteName('a.id') . ' = ' . $articleId);
|
||||
$article = $cachedArticle;
|
||||
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
if (!$article) {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName([
|
||||
'a.created', 'a.modified', 'a.publish_up',
|
||||
]))
|
||||
->select($db->quoteName('u.name', 'author_name'))
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON ' . $db->quoteName('u.id') . ' = ' . $db->quoteName('a.created_by'))
|
||||
->where($db->quoteName('a.id') . ' = ' . $articleId);
|
||||
|
||||
$db->setQuery($query);
|
||||
$article = $db->loadObject();
|
||||
}
|
||||
|
||||
if (!$article) {
|
||||
return null;
|
||||
@@ -60,10 +65,12 @@ class JsonLdBuilder
|
||||
'dateModified' => $article->modified ?: $article->created,
|
||||
];
|
||||
|
||||
if (!empty($article->name)) {
|
||||
$authorName = $article->author_name ?? '';
|
||||
|
||||
if (!empty($authorName)) {
|
||||
$schema['author'] = [
|
||||
'@type' => 'Person',
|
||||
'name' => $article->name,
|
||||
'name' => $authorName,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -152,6 +159,95 @@ class JsonLdBuilder
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Product schema for a MokoSuiteShop product.
|
||||
*
|
||||
* @param int $productId CRM product ID
|
||||
* @param string $title Product title
|
||||
* @param string $description Product description
|
||||
* @param string $image Image URL (absolute)
|
||||
* @param object|null $cachedProduct Pre-loaded product data (avoids duplicate query)
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildProduct(int $productId, string $title, string $description, string $image, ?object $cachedProduct = null): ?array
|
||||
{
|
||||
if ($productId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product = $cachedProduct;
|
||||
|
||||
if (!$product) {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('p.sku, p.price, p.currency, p.stock_qty')
|
||||
->from($db->quoteName('#__mokosuite_crm_products', 'p'))
|
||||
->where($db->quoteName('p.id') . ' = ' . $productId)
|
||||
->where($db->quoteName('p.published') . ' = 1');
|
||||
|
||||
$db->setQuery($query);
|
||||
$product = $db->loadObject();
|
||||
}
|
||||
|
||||
if (!$product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Product',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'url' => Uri::getInstance()->toString(),
|
||||
];
|
||||
|
||||
if (!empty($product->sku)) {
|
||||
$schema['sku'] = $product->sku;
|
||||
}
|
||||
|
||||
if (!empty($image)) {
|
||||
$schema['image'] = $image;
|
||||
}
|
||||
|
||||
// Offers (pricing and availability)
|
||||
$availability = ((float) $product->stock_qty > 0)
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/OutOfStock';
|
||||
|
||||
$schema['offers'] = [
|
||||
'@type' => 'Offer',
|
||||
'price' => number_format((float) $product->price, 2, '.', ''),
|
||||
'priceCurrency' => $product->currency ?: 'USD',
|
||||
'availability' => $availability,
|
||||
'url' => Uri::getInstance()->toString(),
|
||||
];
|
||||
|
||||
// Aggregate rating from reviews if available
|
||||
try {
|
||||
$reviewQuery = $db->getQuery(true)
|
||||
->select('COUNT(*) AS review_count, AVG(rating) AS avg_rating')
|
||||
->from($db->quoteName('#__mokoshop_reviews'))
|
||||
->where($db->quoteName('product_id') . ' = ' . $productId)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('approved'));
|
||||
|
||||
$db->setQuery($reviewQuery);
|
||||
$rating = $db->loadObject();
|
||||
|
||||
if ($rating && (int) $rating->review_count > 0) {
|
||||
$schema['aggregateRating'] = [
|
||||
'@type' => 'AggregateRating',
|
||||
'ratingValue' => round((float) $rating->avg_rating, 1),
|
||||
'reviewCount' => (int) $rating->review_count,
|
||||
];
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
// Reviews table may not exist if MokoSuiteShop reviews module not installed
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a schema array to a JSON-LD script tag string.
|
||||
*
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoJoomOpenGraph</name>
|
||||
<version>01.00.03-dev</version>
|
||||
<version>01.00.08-dev</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @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
|
||||
-->
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoJoomOpenGraph</name>
|
||||
<name>Package - MokoSuiteOpenGraph</name>
|
||||
<packagename>mokoog</packagename>
|
||||
<version>01.00.03-dev</version>
|
||||
<version>01.00.08-dev</version>
|
||||
<creationDate>2026-05-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -30,9 +30,6 @@
|
||||
<language tag="en-GB">language/en-GB/pkg_mokoog.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<updateservers>
|
||||
<server type="extension" name="MokoJoomOpenGraph Updates">https://git.mokoconsulting.tech/MokoConsulting/MokoJoomOpenGraph/updates.xml</server>
|
||||
</updateservers>
|
||||
<dlid prefix="dlid=" suffix=""/>
|
||||
<blockChildUninstall>true</blockChildUninstall>
|
||||
</extension>
|
||||
|
||||
+9
-3
@@ -82,7 +82,9 @@ class Pkg_MokoOGInstallerScript
|
||||
$key = $db->loadResult();
|
||||
if (!empty($key)) { $this->savedDownloadKey = $key; }
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
catch (\Throwable $e) {
|
||||
\Joomla\CMS\Log\Log::add('MokoOG saveDownloadKey: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
|
||||
}
|
||||
}
|
||||
|
||||
private function restoreDownloadKey(): void
|
||||
@@ -112,7 +114,9 @@ class Pkg_MokoOGInstallerScript
|
||||
)->execute();
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
catch (\Throwable $e) {
|
||||
\Joomla\CMS\Log\Log::add('MokoOG restoreDownloadKey: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
|
||||
}
|
||||
}
|
||||
|
||||
private function warnMissingLicenseKey(): void
|
||||
@@ -147,6 +151,8 @@ class Pkg_MokoOGInstallerScript
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
catch (\Throwable $e) {}
|
||||
catch (\Throwable $e) {
|
||||
\Joomla\CMS\Log\Log::add('MokoOG warnMissingLicenseKey: ' . $e->getMessage(), \Joomla\CMS\Log\Log::WARNING, 'mokoog');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user