diff --git a/source/packages/com_mokoog/sql/install.mysql.sql b/source/packages/com_mokoog/sql/install.mysql.sql index 1bce7f2..86e2c45 100644 --- a/source/packages/com_mokoog/sql/install.mysql.sql +++ b/source/packages/com_mokoog/sql/install.mysql.sql @@ -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 '', diff --git a/source/packages/com_mokoog/sql/updates/mysql/01.04.00.sql b/source/packages/com_mokoog/sql/updates/mysql/01.04.00.sql new file mode 100644 index 0000000..225089e --- /dev/null +++ b/source/packages/com_mokoog/sql/updates/mysql/01.04.00.sql @@ -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`; diff --git a/source/packages/plg_content_mokoog/forms/mokoog.xml b/source/packages/plg_content_mokoog/forms/mokoog.xml index b5168e3..08635ce 100644 --- a/source/packages/plg_content_mokoog/forms/mokoog.xml +++ b/source/packages/plg_content_mokoog/forms/mokoog.xml @@ -101,5 +101,25 @@ validate="url" /> +
+ + + + + + + +
+
+ + + + + + + +
diff --git a/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini index e5859fa..85fe8f2 100644 --- a/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini +++ b/source/packages/plg_content_mokoog/language/en-GB/plg_content_mokoog.ini @@ -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)." diff --git a/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini index e5859fa..9a7634a 100644 --- a/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini +++ b/source/packages/plg_content_mokoog/language/en-US/plg_content_mokoog.ini @@ -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)." diff --git a/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php b/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php index b7bac0a..9d93f0e 100644 --- a/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php +++ b/source/packages/plg_content_mokoog/src/Extension/MokoOGContent.php @@ -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. * diff --git a/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini index 2c8029d..b6df7cb 100644 --- a/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini +++ b/source/packages/plg_system_mokoog/language/en-GB/plg_system_mokoog.ini @@ -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." diff --git a/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini index 2c8029d..b6df7cb 100644 --- a/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini +++ b/source/packages/plg_system_mokoog/language/en-US/plg_system_mokoog.ini @@ -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." diff --git a/source/packages/plg_system_mokoog/mokoog.xml b/source/packages/plg_system_mokoog/mokoog.xml index 07d9a89..5a5d246 100644 --- a/source/packages/plg_system_mokoog/mokoog.xml +++ b/source/packages/plg_system_mokoog/mokoog.xml @@ -169,6 +169,28 @@ + + + + + + + + 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]>\s*((?:]*>.*?<\/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>/si', $content, $olMatch)) { + return []; + } + + if (!preg_match_all('/]*>(.*?)<\/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. * diff --git a/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php b/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php index 649bf07..5a84e6e 100644 --- a/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php +++ b/source/packages/plg_system_mokoog/src/Helper/JsonLdBuilder.php @@ -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. *