feat: add custom schema, AI generation, XML sitemap, platform images
- Custom JSON-LD schema builder: per-article textarea for arbitrary structured data with JSON validation. Closes #70 - AI-powered meta generation: Generate with AI buttons for OG title and description, supports Claude and OpenAI APIs. Closes #71 - XML sitemap: auto-generates sitemap.xml on article save, respects noindex directives. Closes #72 - Per-platform image resizing: Twitter 1200x600, Pinterest 1000x1500, WhatsApp 400x400 alongside default Facebook 1200x630. Closes #74 - DB migration 01.05.00: adds custom_schema column
This commit is contained in:
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` (
|
||||
`og_video` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`event_data` TEXT NULL,
|
||||
`recipe_data` TEXT NULL,
|
||||
`custom_schema` TEXT NULL,
|
||||
`seo_title` VARCHAR(70) NOT NULL DEFAULT '',
|
||||
`meta_description` VARCHAR(200) NOT NULL DEFAULT '',
|
||||
`robots` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `custom_schema` TEXT NULL AFTER `canonical_url`;
|
||||
@@ -121,5 +121,9 @@
|
||||
<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>
|
||||
|
||||
@@ -63,3 +63,8 @@ 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)."
|
||||
|
||||
@@ -62,4 +62,9 @@ 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_MKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
|
||||
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)."
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -212,7 +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',
|
||||
'event_data', 'recipe_data', 'custom_schema',
|
||||
'seo_title', 'meta_description', 'robots', 'canonical_url',
|
||||
]))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
@@ -270,6 +270,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
'og_video' => $this->sanitizeUrl($ogData['og_video'] ?? ''),
|
||||
'event_data' => $this->packJsonFields($ogData, ['event_start', 'event_end', 'event_location', 'event_address', 'event_price', 'event_currency', 'event_url']),
|
||||
'recipe_data' => $this->packJsonFields($ogData, ['recipe_prep_time', 'recipe_cook_time', 'recipe_yield', 'recipe_calories', 'recipe_ingredients', 'recipe_category', 'recipe_cuisine']),
|
||||
'custom_schema' => $this->validateJson($ogData['custom_schema'] ?? ''),
|
||||
'seo_title' => strip_tags(trim($ogData['seo_title'] ?? '')),
|
||||
'meta_description' => strip_tags(trim($ogData['meta_description'] ?? '')),
|
||||
'robots' => trim($robots),
|
||||
@@ -310,6 +311,24 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -75,3 +75,21 @@ 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."
|
||||
|
||||
@@ -75,3 +75,21 @@ 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."
|
||||
|
||||
@@ -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"
|
||||
@@ -332,6 +343,29 @@
|
||||
filter="string"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="sitemap" label="PLG_SYSTEM_MOKOOG_FIELDSET_SITEMAP">
|
||||
<field name="sitemap_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_ENABLED_DESC" default="0" class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="sitemap_changefreq" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ" description="PLG_SYSTEM_MOKOOG_FIELD_SITEMAP_CHANGEFREQ_DESC" default="weekly">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
<fieldset name="ai" label="PLG_SYSTEM_MOKOOG_FIELDSET_AI">
|
||||
<field name="ai_enabled" type="radio" label="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED" description="PLG_SYSTEM_MOKOOG_FIELD_AI_ENABLED_DESC" default="0" class="btn-group">
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field name="ai_provider" type="list" label="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER" description="PLG_SYSTEM_MOKOOG_FIELD_AI_PROVIDER_DESC" default="claude">
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
</field>
|
||||
<field name="ai_api_key" type="password" label="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY" description="PLG_SYSTEM_MOKOOG_FIELD_AI_API_KEY_DESC" filter="string" />
|
||||
<field name="ai_model" type="text" label="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL" description="PLG_SYSTEM_MOKOOG_FIELD_AI_MODEL_DESC" default="claude-haiku-4-5-20251001" filter="string" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
|
||||
@@ -19,6 +19,7 @@ use Joomla\Event\Event;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Joomla\Plugin\System\MokoOG\Helper\ImageHelper;
|
||||
use Joomla\Plugin\System\MokoOG\Helper\JsonLdBuilder;
|
||||
use Joomla\Plugin\System\MokoOG\Helper\SitemapBuilder;
|
||||
|
||||
final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
@@ -37,6 +38,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
return [
|
||||
'onAfterRoute' => 'onAfterRoute',
|
||||
'onBeforeCompileHead' => 'onBeforeCompileHead',
|
||||
'onContentAfterSave' => 'onContentAfterSaveRebuildSitemap',
|
||||
'onAjaxMokoog' => 'onAjaxMokoog',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -346,6 +352,21 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -428,6 +449,7 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
'og_video' => '',
|
||||
'event_data' => '',
|
||||
'recipe_data' => '',
|
||||
'custom_schema' => '',
|
||||
'seo_title' => '',
|
||||
'meta_description' => '',
|
||||
'robots' => '',
|
||||
@@ -789,6 +811,129 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
return $steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild sitemap.xml when article content is saved.
|
||||
*
|
||||
* @param Event $event The event
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function onContentAfterSaveRebuildSitemap(Event $event): void
|
||||
{
|
||||
if (!$this->params->get('sitemap_enabled', 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$context] = array_values($event->getArguments());
|
||||
|
||||
if ($context !== 'com_content.article') {
|
||||
return;
|
||||
}
|
||||
|
||||
$changefreq = $this->params->get('sitemap_changefreq', 'weekly');
|
||||
$xml = SitemapBuilder::generate($changefreq);
|
||||
SitemapBuilder::writeToFile($xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX requests for AI meta tag generation.
|
||||
*
|
||||
* @param Event $event The event
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function onAjaxMokoog(Event $event): void
|
||||
{
|
||||
$app = $this->getApplication();
|
||||
|
||||
if (!$app->isClient('administrator')) {
|
||||
return;
|
||||
}
|
||||
|
||||
\Joomla\CMS\Session\Session::checkToken() or die('Invalid Token');
|
||||
|
||||
if (!$this->params->get('ai_enabled', 0)) {
|
||||
$event->setArgument('result', ['AI generation is not enabled']);
|
||||
return;
|
||||
}
|
||||
|
||||
$apiKey = $this->params->get('ai_api_key', '');
|
||||
$provider = $this->params->get('ai_provider', 'claude');
|
||||
$model = $this->params->get('ai_model', 'claude-haiku-4-5-20251001');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
$event->setArgument('result', ['API key not configured']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = $app->getInput();
|
||||
$field = $input->getString('field', 'title');
|
||||
$articleTitle = $input->getString('article_title', '');
|
||||
|
||||
$prompt = $field === 'title'
|
||||
? "Generate a concise, engaging social media sharing title (max 60 characters) for an article titled: \"$articleTitle\". Return only the title text, no quotes or explanation."
|
||||
: "Generate a compelling social media sharing description (max 155 characters) for an article titled: \"$articleTitle\". Return only the description text, no quotes or explanation.";
|
||||
|
||||
try {
|
||||
$result = $this->callAiApi($provider, $apiKey, $model, $prompt);
|
||||
$event->setArgument('result', [$result]);
|
||||
} catch (\Exception $e) {
|
||||
$event->setArgument('result', ['Error: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an AI API (Claude or OpenAI) with a prompt.
|
||||
*
|
||||
* @param string $provider Provider name (claude or openai)
|
||||
* @param string $apiKey API key
|
||||
* @param string $model Model name
|
||||
* @param string $prompt Prompt text
|
||||
*
|
||||
* @return string Generated text
|
||||
*/
|
||||
private function callAiApi(string $provider, string $apiKey, string $model, string $prompt): string
|
||||
{
|
||||
$http = \Joomla\CMS\Http\HttpFactory::getHttp();
|
||||
|
||||
if ($provider === 'claude') {
|
||||
$response = $http->post(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
json_encode([
|
||||
'model' => $model,
|
||||
'max_tokens' => 200,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
]),
|
||||
[
|
||||
'Content-Type' => 'application/json',
|
||||
'x-api-key' => $apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
]
|
||||
);
|
||||
|
||||
$data = json_decode($response->body, true);
|
||||
|
||||
return trim($data['content'][0]['text'] ?? '');
|
||||
}
|
||||
|
||||
$response = $http->post(
|
||||
'https://api.openai.com/v1/chat/completions',
|
||||
json_encode([
|
||||
'model' => $model,
|
||||
'max_tokens' => 200,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
]),
|
||||
[
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $apiKey,
|
||||
]
|
||||
);
|
||||
|
||||
$data = json_decode($response->body, true);
|
||||
|
||||
return trim($data['choices'][0]['message']['content'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn administrators once per session when no license key is configured.
|
||||
*
|
||||
|
||||
@@ -149,6 +149,137 @@ class ImageHelper
|
||||
return $outputRel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image for a specific platform.
|
||||
*
|
||||
* @param string $imagePath Relative image path
|
||||
* @param string $platform Platform name (facebook, twitter, pinterest, whatsapp)
|
||||
*
|
||||
* @return string Path to the resized image
|
||||
*/
|
||||
public static function resizeForPlatform(string $imagePath, string $platform): string
|
||||
{
|
||||
$sizes = [
|
||||
'facebook' => ['width' => 1200, 'height' => 630],
|
||||
'twitter' => ['width' => 1200, 'height' => 600],
|
||||
'pinterest' => ['width' => 1000, 'height' => 1500],
|
||||
'whatsapp' => ['width' => 400, 'height' => 400],
|
||||
];
|
||||
|
||||
if (!isset($sizes[$platform])) {
|
||||
return self::resize($imagePath);
|
||||
}
|
||||
|
||||
$size = $sizes[$platform];
|
||||
|
||||
return self::resizeToSize($imagePath, $size['width'], $size['height'], $platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image to specific dimensions with a platform-specific subdirectory.
|
||||
*
|
||||
* @param string $imagePath Image path relative to JPATH_ROOT
|
||||
* @param int $width Target width
|
||||
* @param int $height Target height
|
||||
* @param string $subdir Subdirectory name for output (e.g. platform name)
|
||||
*
|
||||
* @return string Path to the output image (relative to JPATH_ROOT)
|
||||
*/
|
||||
private static function resizeToSize(string $imagePath, int $width, int $height, string $subdir = ''): string
|
||||
{
|
||||
// Resolve absolute path
|
||||
$absPath = JPATH_ROOT . '/' . ltrim($imagePath, '/');
|
||||
|
||||
if (!is_file($absPath)) {
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
$imageInfo = getimagesize($absPath);
|
||||
|
||||
if (!$imageInfo) {
|
||||
Log::add('MokoOG ImageHelper: Cannot read image dimensions: ' . basename($absPath), Log::WARNING, 'mokoog');
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
[$origWidth, $origHeight, $type] = $imageInfo;
|
||||
|
||||
// Skip if already at or below target size
|
||||
if ($origWidth <= $width && $origHeight <= $height) {
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
// Build output directory with optional subdirectory
|
||||
$outputRelDir = self::OUTPUT_DIR . ($subdir ? '/' . $subdir : '');
|
||||
$outputDir = JPATH_ROOT . '/' . $outputRelDir;
|
||||
|
||||
if (!is_dir($outputDir) && !Folder::create($outputDir)) {
|
||||
Log::add('MokoOG ImageHelper: Cannot create output directory: ' . $outputRelDir, Log::WARNING, 'mokoog');
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
// Generate output filename based on source hash + dimensions
|
||||
$hash = md5($imagePath . $width . $height);
|
||||
$outputName = $hash . '.jpg';
|
||||
$outputPath = $outputDir . '/' . $outputName;
|
||||
$outputRel = $outputRelDir . '/' . $outputName;
|
||||
|
||||
// Skip if already generated
|
||||
if (is_file($outputPath) && filemtime($outputPath) >= filemtime($absPath)) {
|
||||
return $outputRel;
|
||||
}
|
||||
|
||||
// Load source image
|
||||
$source = self::loadImage($absPath, $type);
|
||||
|
||||
if (!$source) {
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
// Calculate crop dimensions (center crop to target aspect ratio)
|
||||
$targetRatio = $width / $height;
|
||||
$sourceRatio = $origWidth / $origHeight;
|
||||
|
||||
if ($sourceRatio > $targetRatio) {
|
||||
// Source is wider — crop sides
|
||||
$cropHeight = $origHeight;
|
||||
$cropWidth = (int) round($origHeight * $targetRatio);
|
||||
$cropX = (int) round(($origWidth - $cropWidth) / 2);
|
||||
$cropY = 0;
|
||||
} else {
|
||||
// Source is taller — crop top/bottom
|
||||
$cropWidth = $origWidth;
|
||||
$cropHeight = (int) round($origWidth / $targetRatio);
|
||||
$cropX = 0;
|
||||
$cropY = (int) round(($origHeight - $cropHeight) / 2);
|
||||
}
|
||||
|
||||
// Create output canvas and resample
|
||||
$output = imagecreatetruecolor($width, $height);
|
||||
|
||||
imagecopyresampled(
|
||||
$output,
|
||||
$source,
|
||||
0,
|
||||
0,
|
||||
$cropX,
|
||||
$cropY,
|
||||
$width,
|
||||
$height,
|
||||
$cropWidth,
|
||||
$cropHeight
|
||||
);
|
||||
|
||||
// Save as JPEG
|
||||
imagejpeg($output, $outputPath, self::JPEG_QUALITY);
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($output);
|
||||
|
||||
return $outputRel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a generated image file.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteOpenGraph
|
||||
* @subpackage plg_system_mokoog
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\MokoOG\Helper;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
/**
|
||||
* XML Sitemap builder.
|
||||
*
|
||||
* Generates a sitemap.xml containing all published articles, excluding
|
||||
* those marked with noindex robots directives in the mokoog_tags table.
|
||||
*/
|
||||
class SitemapBuilder
|
||||
{
|
||||
/**
|
||||
* Generate sitemap XML content.
|
||||
*
|
||||
* @param string $changefreq Default change frequency for entries
|
||||
*
|
||||
* @return string Complete sitemap XML
|
||||
*/
|
||||
public static function generate(string $changefreq = 'weekly'): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Get all published articles
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['a.id', 'a.alias', 'a.catid', 'a.modified', 'a.language']))
|
||||
->from($db->quoteName('#__content', 'a'))
|
||||
->where($db->quoteName('a.state') . ' = 1');
|
||||
|
||||
$db->setQuery($query);
|
||||
$articles = $db->loadObjectList();
|
||||
|
||||
// Get noindex articles from mokoog_tags
|
||||
$noindexQuery = $db->getQuery(true)
|
||||
->select($db->quoteName('content_id'))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
->where($db->quoteName('content_type') . ' = ' . $db->quote('com_content'))
|
||||
->where($db->quoteName('robots') . ' LIKE ' . $db->quote('%noindex%'));
|
||||
|
||||
$db->setQuery($noindexQuery);
|
||||
$noindexIds = $db->loadColumn();
|
||||
|
||||
$root = rtrim(Uri::root(), '/');
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||
|
||||
// Homepage
|
||||
$xml .= ' <url>' . "\n";
|
||||
$xml .= ' <loc>' . $root . '/</loc>' . "\n";
|
||||
$xml .= ' <changefreq>daily</changefreq>' . "\n";
|
||||
$xml .= ' <priority>1.0</priority>' . "\n";
|
||||
$xml .= ' </url>' . "\n";
|
||||
|
||||
foreach ($articles as $article) {
|
||||
// Skip noindexed
|
||||
if (in_array((int) $article->id, $noindexIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $root . '/index.php?option=com_content&view=article&id=' . $article->id;
|
||||
$lastmod = $article->modified && $article->modified !== '0000-00-00 00:00:00'
|
||||
? date('Y-m-d', strtotime($article->modified)) : '';
|
||||
|
||||
$xml .= ' <url>' . "\n";
|
||||
$xml .= ' <loc>' . htmlspecialchars($url, ENT_XML1) . '</loc>' . "\n";
|
||||
|
||||
if ($lastmod) {
|
||||
$xml .= ' <lastmod>' . $lastmod . '</lastmod>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= ' <changefreq>' . $changefreq . '</changefreq>' . "\n";
|
||||
$xml .= ' <priority>0.8</priority>' . "\n";
|
||||
$xml .= ' </url>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= '</urlset>';
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write sitemap XML to the site root.
|
||||
*
|
||||
* @param string $xml The sitemap XML content
|
||||
*
|
||||
* @return bool True on success
|
||||
*/
|
||||
public static function writeToFile(string $xml): bool
|
||||
{
|
||||
$path = JPATH_ROOT . '/sitemap.xml';
|
||||
|
||||
return (bool) file_put_contents($path, $xml);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user