feat: add FAQ, HowTo, Event, and Recipe JSON-LD schema types
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s

- 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:
Jonathan Miller
2026-06-23 12:19:08 -05:00
parent c871b7d30d
commit 872074cd5b
11 changed files with 592 additions and 1 deletions
@@ -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.
*