feat: add FAQ, HowTo, Event, and Recipe JSON-LD schema types
- FAQ: auto-detects h3/h4 + paragraph patterns, outputs FAQPage (#62) - HowTo: auto-detects ordered lists, outputs HowTo with steps (#63) - Event: per-article fields (dates, venue, tickets), event_data JSON column, outputs Event schema (#64) - Recipe: per-article fields (times, ingredients, nutrition), recipe_data JSON column, outputs Recipe schema (#66) - DB migration 01.04.00: adds event_data and recipe_data columns Closes #62, closes #63, closes #64, closes #66
This commit is contained in:
@@ -13,6 +13,8 @@ CREATE TABLE IF NOT EXISTS `#__mokoog_tags` (
|
||||
`og_image` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`og_type` VARCHAR(50) NOT NULL DEFAULT 'article',
|
||||
`og_video` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`event_data` TEXT NULL,
|
||||
`recipe_data` TEXT NULL,
|
||||
`seo_title` VARCHAR(70) NOT NULL DEFAULT '',
|
||||
`meta_description` VARCHAR(200) NOT NULL DEFAULT '',
|
||||
`robots` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
--
|
||||
-- MokoJoomOpenGraph 01.04.00 - Add event_data and recipe_data columns
|
||||
--
|
||||
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `event_data` TEXT NULL AFTER `og_video`;
|
||||
ALTER TABLE `#__mokoog_tags` ADD COLUMN `recipe_data` TEXT NULL AFTER `event_data`;
|
||||
@@ -101,5 +101,25 @@
|
||||
validate="url"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_event" label="PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC">
|
||||
<field name="event_start" type="calendar" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_START" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC" format="%Y-%m-%d %H:%M" filter="string" showtime="true" />
|
||||
<field name="event_end" type="calendar" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_END" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC" format="%Y-%m-%d %H:%M" filter="string" showtime="true" />
|
||||
<field name="event_location" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC" filter="string" />
|
||||
<field name="event_address" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC" filter="string" />
|
||||
<field name="event_price" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC" filter="string" />
|
||||
<field name="event_currency" type="text" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC" filter="string" default="USD" />
|
||||
<field name="event_url" type="url" label="PLG_CONTENT_MOKOOG_FIELD_EVENT_URL" description="PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC" filter="url" />
|
||||
</fieldset>
|
||||
<fieldset name="mokoog_recipe" label="PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL"
|
||||
description="PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC">
|
||||
<field name="recipe_prep_time" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC" filter="string" hint="PT15M" />
|
||||
<field name="recipe_cook_time" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC" filter="string" hint="PT30M" />
|
||||
<field name="recipe_yield" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC" filter="string" hint="4 servings" />
|
||||
<field name="recipe_calories" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC" filter="string" hint="350" />
|
||||
<field name="recipe_ingredients" type="textarea" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC" filter="string" rows="5" hint="One ingredient per line" />
|
||||
<field name="recipe_category" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC" filter="string" hint="Dessert" />
|
||||
<field name="recipe_cuisine" type="text" label="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE" description="PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC" filter="string" hint="Italian" />
|
||||
</fieldset>
|
||||
</fields>
|
||||
</form>
|
||||
|
||||
@@ -29,3 +29,37 @@ PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this
|
||||
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL="Event Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC="Optional event information for JSON-LD Event schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START="Start Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC="Event start date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END="End Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC="Event end date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION="Venue Name"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC="Name of the event venue or location."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS="Venue Address"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC="Full address of the event venue."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE="Ticket Price"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC="Ticket price (e.g. 50.00). Leave blank if free."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY="Currency"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC="Currency code for ticket price (e.g. USD, EUR, GBP)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL="Ticket URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC="URL where tickets can be purchased."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL="Recipe Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC="Optional recipe information for JSON-LD Recipe schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME="Prep Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC="Preparation time in ISO 8601 duration format (e.g. PT15M for 15 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME="Cook Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC="Cooking time in ISO 8601 duration format (e.g. PT30M for 30 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD="Yield"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC="Number of servings or yield (e.g. 4 servings, 1 loaf)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES="Calories"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC="Calories per serving."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS="Ingredients"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC="One ingredient per line."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
|
||||
|
||||
@@ -29,3 +29,37 @@ PLG_CONTENT_MOKOOG_FIELD_ROBOTS_DESC="Search engine indexing directives for this
|
||||
PLG_CONTENT_MOKOOG_ROBOTS_DEFAULT="- Use default (index, follow) -"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL="Canonical URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_CANONICAL_URL_DESC="Override the canonical URL for this page. Leave blank to use the current URL."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_LABEL="Event Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_EVENT_DESC="Optional event information for JSON-LD Event schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START="Start Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_START_DESC="Event start date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END="End Date/Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_END_DESC="Event end date and time."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION="Venue Name"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_LOCATION_DESC="Name of the event venue or location."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS="Venue Address"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_ADDRESS_DESC="Full address of the event venue."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE="Ticket Price"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_PRICE_DESC="Ticket price (e.g. 50.00). Leave blank if free."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY="Currency"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_CURRENCY_DESC="Currency code for ticket price (e.g. USD, EUR, GBP)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL="Ticket URL"
|
||||
PLG_CONTENT_MOKOOG_FIELD_EVENT_URL_DESC="URL where tickets can be purchased."
|
||||
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_LABEL="Recipe Details"
|
||||
PLG_CONTENT_MOKOOG_FIELDSET_RECIPE_DESC="Optional recipe information for JSON-LD Recipe schema."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME="Prep Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_PREP_TIME_DESC="Preparation time in ISO 8601 duration format (e.g. PT15M for 15 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME="Cook Time"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_COOK_TIME_DESC="Cooking time in ISO 8601 duration format (e.g. PT30M for 30 minutes)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD="Yield"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_YIELD_DESC="Number of servings or yield (e.g. 4 servings, 1 loaf)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES="Calories"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CALORIES_DESC="Calories per serving."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS="Ingredients"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_INGREDIENTS_DESC="One ingredient per line."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY="Recipe Category"
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CATEGORY_DESC="Category (e.g. Dessert, Appetizer, Main course)."
|
||||
PLG_CONTENT_MOKOOG_FIELD_RECIPE_CUISINE="Cuisine"
|
||||
PLG_CONTENT_MKOOG_FIELD_RECIPE_CUISINE_DESC="Type of cuisine (e.g. Italian, Mexican, American)."
|
||||
|
||||
@@ -98,7 +98,24 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
$ogData = $this->loadOgData($contentType, $id, $language);
|
||||
|
||||
if ($ogData) {
|
||||
$form->bind(['mokoog' => (array) $ogData]);
|
||||
$bindData = (array) $ogData;
|
||||
|
||||
// Unpack JSON blob fields into individual form fields
|
||||
foreach (['event_data', 'recipe_data'] as $jsonField) {
|
||||
if (!empty($bindData[$jsonField])) {
|
||||
$decoded = json_decode($bindData[$jsonField], true);
|
||||
|
||||
if (\is_array($decoded)) {
|
||||
foreach ($decoded as $key => $value) {
|
||||
$bindData[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unset($bindData[$jsonField]);
|
||||
}
|
||||
|
||||
$form->bind(['mokoog' => $bindData]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,6 +212,7 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName([
|
||||
'og_title', 'og_description', 'og_image', 'og_type', 'og_video',
|
||||
'event_data', 'recipe_data',
|
||||
'seo_title', 'meta_description', 'robots', 'canonical_url',
|
||||
]))
|
||||
->from($db->quoteName('#__mokoog_tags'))
|
||||
@@ -250,6 +268,8 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
'og_image' => trim($ogData['og_image'] ?? ''),
|
||||
'og_type' => trim($ogData['og_type'] ?? 'article'),
|
||||
'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']),
|
||||
'seo_title' => strip_tags(trim($ogData['seo_title'] ?? '')),
|
||||
'meta_description' => strip_tags(trim($ogData['meta_description'] ?? '')),
|
||||
'robots' => trim($robots),
|
||||
@@ -267,6 +287,29 @@ final class MokoOGContent extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack form fields into a JSON string for storage.
|
||||
*
|
||||
* @param array $ogData Form data array
|
||||
* @param array $fields Field names to pack
|
||||
*
|
||||
* @return string JSON string or empty
|
||||
*/
|
||||
private function packJsonFields(array $ogData, array $fields): string
|
||||
{
|
||||
$data = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$val = trim($ogData[$field] ?? '');
|
||||
|
||||
if ($val !== '') {
|
||||
$data[$field] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
return !empty($data) ? json_encode($data) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a URL to only allow http/https schemes.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,10 @@ PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ="JSON-LD FAQ Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC="Auto-detect FAQ sections from article headings (h3/h4 + paragraphs) and output FAQPage structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO="JSON-LD HowTo Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC="Auto-detect step-by-step instructions from ordered lists (ol/li) and output HowTo structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE="Auto-resize Images"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_AUTO_RESIZE_DESC="Automatically resize images to 1200x630px (Facebook recommended) using center crop. Generated images are saved to images/mokoog/generated/."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED="Enable JSON-LD"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_ENABLED_DESC="Output JSON-LD structured data (Article, WebPage) for Google rich results."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ="JSON-LD FAQ Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC="Auto-detect FAQ sections from article headings (h3/h4 + paragraphs) and output FAQPage structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO="JSON-LD HowTo Schema"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC="Auto-detect step-by-step instructions from ordered lists (ol/li) and output HowTo structured data."
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS="JSON-LD Breadcrumbs"
|
||||
PLG_SYSTEM_MOKOOG_FIELD_JSONLD_BREADCRUMBS_DESC="Output BreadcrumbList JSON-LD schema from Joomla's pathway."
|
||||
|
||||
|
||||
@@ -169,6 +169,28 @@
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_faq"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_FAQ_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_howto"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO"
|
||||
description="PLG_SYSTEM_MOKOOG_FIELD_JSONLD_HOWTO_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="jsonld_breadcrumbs"
|
||||
type="radio"
|
||||
|
||||
@@ -290,6 +290,62 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
// FAQ schema (auto-detected from article headings)
|
||||
if ($this->params->get('jsonld_faq', 1) && $option === 'com_content' && $view === 'article' && $id > 0) {
|
||||
$faqItems = $this->extractFaqFromContent($id);
|
||||
|
||||
if (!empty($faqItems)) {
|
||||
$faqSchema = JsonLdBuilder::buildFaq($faqItems);
|
||||
|
||||
if ($faqSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($faqSchema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HowTo schema (auto-detected from ordered lists)
|
||||
if ($this->params->get('jsonld_howto', 1) && $option === 'com_content' && $view === 'article' && $id > 0) {
|
||||
$howToSteps = $this->extractHowToFromContent($id);
|
||||
|
||||
if (!empty($howToSteps)) {
|
||||
$howToSchema = JsonLdBuilder::buildHowTo($title, $howToSteps, $imageUrl);
|
||||
|
||||
if ($howToSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($howToSchema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event JSON-LD from per-article event data
|
||||
$eventJson = $ogData->event_data ?? '';
|
||||
|
||||
if (!empty($eventJson)) {
|
||||
$eventObj = json_decode($eventJson);
|
||||
|
||||
if ($eventObj && !empty($eventObj->event_start)) {
|
||||
$eventSchema = JsonLdBuilder::buildEvent($title, $description, $imageUrl, $eventObj);
|
||||
|
||||
if ($eventSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($eventSchema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recipe JSON-LD from per-article recipe data
|
||||
$recipeJson = $ogData->recipe_data ?? '';
|
||||
|
||||
if (!empty($recipeJson)) {
|
||||
$recipeObj = json_decode($recipeJson);
|
||||
|
||||
if ($recipeObj) {
|
||||
$recipeSchema = JsonLdBuilder::buildRecipe($title, $description, $imageUrl, $recipeObj);
|
||||
|
||||
if ($recipeSchema) {
|
||||
$doc->addCustomTag(JsonLdBuilder::toScriptTag($recipeSchema));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->params->get('jsonld_breadcrumbs', 1)) {
|
||||
$breadcrumbs = JsonLdBuilder::buildBreadcrumbs();
|
||||
|
||||
@@ -370,6 +426,8 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
'og_image' => '',
|
||||
'og_type' => '',
|
||||
'og_video' => '',
|
||||
'event_data' => '',
|
||||
'recipe_data' => '',
|
||||
'seo_title' => '',
|
||||
'meta_description' => '',
|
||||
'robots' => '',
|
||||
@@ -640,6 +698,97 @@ final class MokoOG extends CMSPlugin implements SubscriberInterface
|
||||
return $article->author_name ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract FAQ question/answer pairs from article content.
|
||||
*
|
||||
* @param int $articleId Article ID
|
||||
*
|
||||
* @return array Array of ['question' => '...', 'answer' => '...'] pairs
|
||||
*/
|
||||
private function extractFaqFromContent(int $articleId): array
|
||||
{
|
||||
$article = $this->loadArticle($articleId);
|
||||
|
||||
if (!$article) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = ($article->introtext ?? '') . ($article->fulltext ?? '');
|
||||
|
||||
if (trim($content) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$faqItems = [];
|
||||
|
||||
if (preg_match_all('/<h[34][^>]*>(.*?)<\/h[34]>\s*((?:<p[^>]*>.*?<\/p>\s*)+)/si', $content, $matches, PREG_SET_ORDER)) {
|
||||
foreach ($matches as $match) {
|
||||
$question = trim(strip_tags($match[1]));
|
||||
$answer = trim(strip_tags($match[2]));
|
||||
|
||||
if ($question !== '' && $answer !== '') {
|
||||
$faqItems[] = [
|
||||
'question' => $question,
|
||||
'answer' => $answer,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $faqItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract HowTo steps from ordered lists in article content.
|
||||
*
|
||||
* @param int $articleId Article ID
|
||||
*
|
||||
* @return array Array of ['name' => '...', 'text' => '...'] pairs
|
||||
*/
|
||||
private function extractHowToFromContent(int $articleId): array
|
||||
{
|
||||
$article = $this->loadArticle($articleId);
|
||||
|
||||
if (!$article) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = ($article->introtext ?? '') . ($article->fulltext ?? '');
|
||||
|
||||
if (!preg_match('/<ol[^>]*>(.*?)<\/ol>/si', $content, $olMatch)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!preg_match_all('/<li[^>]*>(.*?)<\/li>/si', $olMatch[1], $liMatches)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$steps = [];
|
||||
|
||||
foreach ($liMatches[1] as $liHtml) {
|
||||
$text = trim(strip_tags($liHtml));
|
||||
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $text;
|
||||
|
||||
if (preg_match('/<(?:b|strong)[^>]*>(.*?)<\/(?:b|strong)>/si', $liHtml, $boldMatch)) {
|
||||
$name = trim(strip_tags($boldMatch[1]));
|
||||
} elseif (preg_match('/^([^.!?]+[.!?])/', $text, $sentenceMatch)) {
|
||||
$name = trim($sentenceMatch[1]);
|
||||
}
|
||||
|
||||
$steps[] = [
|
||||
'name' => $name,
|
||||
'text' => $text,
|
||||
];
|
||||
}
|
||||
|
||||
return $steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn administrators once per session when no license key is configured.
|
||||
*
|
||||
|
||||
@@ -382,6 +382,279 @@ class JsonLdBuilder
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FAQPage schema from question/answer pairs.
|
||||
*
|
||||
* @param array $questions Array of ['question' => '...', 'answer' => '...'] pairs
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildFaq(array $questions): ?array
|
||||
{
|
||||
if (empty($questions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mainEntity = [];
|
||||
|
||||
foreach ($questions as $item) {
|
||||
$question = trim($item['question'] ?? '');
|
||||
$answer = trim($item['answer'] ?? '');
|
||||
|
||||
if ($question === '' || $answer === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mainEntity[] = [
|
||||
'@type' => 'Question',
|
||||
'name' => $question,
|
||||
'acceptedAnswer' => [
|
||||
'@type' => 'Answer',
|
||||
'text' => $answer,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($mainEntity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'FAQPage',
|
||||
'mainEntity' => $mainEntity,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HowTo schema from step-by-step instructions.
|
||||
*
|
||||
* @param string $title HowTo title
|
||||
* @param array $steps Array of ['name' => 'Step title', 'text' => 'Step instructions']
|
||||
* @param string $imageUrl Optional image URL (absolute)
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildHowTo(string $title, array $steps, string $imageUrl = ''): ?array
|
||||
{
|
||||
if (empty($steps)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'HowTo',
|
||||
'name' => $title,
|
||||
];
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
$schema['image'] = $imageUrl;
|
||||
}
|
||||
|
||||
$schema['step'] = [];
|
||||
|
||||
foreach ($steps as $step) {
|
||||
$schema['step'][] = [
|
||||
'@type' => 'HowToStep',
|
||||
'name' => $step['name'],
|
||||
'text' => $step['text'],
|
||||
];
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Event schema from per-article event data.
|
||||
*
|
||||
* @param string $title Event/article title
|
||||
* @param string $description Event description
|
||||
* @param string $imageUrl Image URL (absolute)
|
||||
* @param object $eventData Decoded event_data with event_start, event_end, etc.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildEvent(string $title, string $description, string $imageUrl, object $eventData): ?array
|
||||
{
|
||||
$startDate = $eventData->event_start ?? '';
|
||||
|
||||
if (empty($startDate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Event',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'startDate' => $startDate,
|
||||
'url' => Uri::getInstance()->toString(),
|
||||
];
|
||||
|
||||
$endDate = $eventData->event_end ?? '';
|
||||
|
||||
if (!empty($endDate)) {
|
||||
$schema['endDate'] = $endDate;
|
||||
}
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
$schema['image'] = $imageUrl;
|
||||
}
|
||||
|
||||
$locationName = $eventData->event_location ?? '';
|
||||
$address = $eventData->event_address ?? '';
|
||||
|
||||
if (!empty($locationName) || !empty($address)) {
|
||||
$location = ['@type' => 'Place'];
|
||||
|
||||
if (!empty($locationName)) {
|
||||
$location['name'] = $locationName;
|
||||
}
|
||||
|
||||
if (!empty($address)) {
|
||||
$location['address'] = [
|
||||
'@type' => 'PostalAddress',
|
||||
'streetAddress' => $address,
|
||||
];
|
||||
}
|
||||
|
||||
$schema['location'] = $location;
|
||||
}
|
||||
|
||||
$price = $eventData->event_price ?? '';
|
||||
$currency = $eventData->event_currency ?? 'USD';
|
||||
$ticketUrl = $eventData->event_url ?? '';
|
||||
|
||||
if ($price !== '') {
|
||||
$offer = [
|
||||
'@type' => 'Offer',
|
||||
'price' => number_format((float) $price, 2, '.', ''),
|
||||
'priceCurrency' => $currency ?: 'USD',
|
||||
'availability' => 'https://schema.org/InStock',
|
||||
];
|
||||
|
||||
if (!empty($ticketUrl)) {
|
||||
$offer['url'] = $ticketUrl;
|
||||
}
|
||||
|
||||
$schema['offers'] = $offer;
|
||||
} elseif (!empty($ticketUrl)) {
|
||||
$schema['offers'] = [
|
||||
'@type' => 'Offer',
|
||||
'price' => '0.00',
|
||||
'priceCurrency' => $currency ?: 'USD',
|
||||
'availability' => 'https://schema.org/InStock',
|
||||
'url' => $ticketUrl,
|
||||
];
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Recipe schema from per-article recipe data.
|
||||
*
|
||||
* @param string $title Recipe/article title
|
||||
* @param string $description Recipe/article description
|
||||
* @param string $imageUrl Image URL (absolute)
|
||||
* @param object $recipeData Decoded recipe_data object
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public static function buildRecipe(string $title, string $description, string $imageUrl, object $recipeData): ?array
|
||||
{
|
||||
$fields = ['recipe_prep_time', 'recipe_cook_time', 'recipe_yield', 'recipe_calories', 'recipe_ingredients', 'recipe_category', 'recipe_cuisine'];
|
||||
$hasData = false;
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (!empty($recipeData->$field)) {
|
||||
$hasData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$schema = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Recipe',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'url' => Uri::getInstance()->toString(),
|
||||
];
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
$schema['image'] = $imageUrl;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_prep_time)) {
|
||||
$schema['prepTime'] = $recipeData->recipe_prep_time;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_cook_time)) {
|
||||
$schema['cookTime'] = $recipeData->recipe_cook_time;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_prep_time) && !empty($recipeData->recipe_cook_time)) {
|
||||
try {
|
||||
$prep = new \DateInterval($recipeData->recipe_prep_time);
|
||||
$cook = new \DateInterval($recipeData->recipe_cook_time);
|
||||
$totalMinutes = ($prep->h * 60 + $prep->i) + ($cook->h * 60 + $cook->i);
|
||||
$hours = intdiv($totalMinutes, 60);
|
||||
$minutes = $totalMinutes % 60;
|
||||
$totalTime = 'PT';
|
||||
|
||||
if ($hours > 0) {
|
||||
$totalTime .= $hours . 'H';
|
||||
}
|
||||
|
||||
if ($minutes > 0) {
|
||||
$totalTime .= $minutes . 'M';
|
||||
}
|
||||
|
||||
if ($totalTime !== 'PT') {
|
||||
$schema['totalTime'] = $totalTime;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Invalid duration format
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_yield)) {
|
||||
$schema['recipeYield'] = $recipeData->recipe_yield;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_calories)) {
|
||||
$schema['nutrition'] = [
|
||||
'@type' => 'NutritionInformation',
|
||||
'calories' => $recipeData->recipe_calories . ' calories',
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_ingredients)) {
|
||||
$ingredients = array_filter(
|
||||
array_map('trim', preg_split('/\r\n|\r|\n/', $recipeData->recipe_ingredients)),
|
||||
fn($line) => $line !== ''
|
||||
);
|
||||
|
||||
if (!empty($ingredients)) {
|
||||
$schema['recipeIngredient'] = array_values($ingredients);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_category)) {
|
||||
$schema['recipeCategory'] = $recipeData->recipe_category;
|
||||
}
|
||||
|
||||
if (!empty($recipeData->recipe_cuisine)) {
|
||||
$schema['recipeCuisine'] = $recipeData->recipe_cuisine;
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a schema array to a JSON-LD script tag string.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user