*/ // Abort if this file is accessed directly. if (!defined('ABSPATH')) { exit; } class Metasync_Schema_Markup { private $plugin_name; private $version; private $common; /** Shared instance — prevents MCP tools from re-registering hooks */ private static $instance = null; /** Schema types supported by the content REST/MCP endpoints */ private static $supported_content_types = [ 'article', 'FAQPage', 'product', 'recipe', 'Event', 'JobPosting', 'Review', 'Course', 'Organization', 'Person', 'WebSite', 'NewsArticle', 'LocalBusiness', 'HowTo', 'VideoObject', ]; /** * Returns the shared instance, or creates one if none exists yet. * MCP tools use this instead of `new Metasync_Schema_Markup()` to * avoid re-registering all WordPress hooks on every tool call. */ public static function get_instance() { if (null === self::$instance) { self::$instance = new self('metasync', defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0'); } return self::$instance; } public function __construct($plugin_name, $version) { $this->plugin_name = $plugin_name; $this->version = $version; $this->common = new Metasync_Common(); // Capture first instance so MCP tools can reuse it without re-registering hooks if (null === self::$instance) { self::$instance = $this; } // Initialize hooks add_action('admin_init', [$this, 'add_schema_markup_meta_box']); add_action('save_post', [$this, 'save_schema_markup_data']); add_action('wp_head', [$this, 'output_schema_markup']); add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']); add_action('admin_notices', [$this, 'display_schema_validation_notices']); add_action('rest_api_init', [$this, 'register_schema_content_rest_routes']); } /** * Add the Schema Markup meta box to post and page editors */ public function add_schema_markup_meta_box() { // Don't show metabox if user's role doesn't have plugin access if (!Metasync::current_user_has_plugin_access()) { return; } // Don't show metabox if disabled in Post/Page Editor Settings $general_settings = Metasync::get_option('general', []); if (!empty($general_settings['disable_schema_markup_metabox'])) { return; } // LPS / custom-HTML pages bake their own SEO (incl. any schema) into their HTML // bundle, served before wp_head — so this box does nothing on them. Hide it; the // SEO read-only notice covers the messaging. $lps_post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0); if (function_exists('metasync_is_custom_or_lps_page') && $lps_post_id > 0 && metasync_is_custom_or_lps_page($lps_post_id)) { return; } $plugin_name = Metasync::get_effective_plugin_name(); add_meta_box( 'metasync-schema-markup', "Schema Markup by $plugin_name", [$this, 'schema_markup_meta_box_display'], $this->get_schema_supported_post_types(), 'normal', 'default' ); } /** * Display the Schema Markup meta box */ public function schema_markup_meta_box_display($post) { // Get existing schema data $schema_data = get_post_meta($post->ID, 'metasync_schema_markup', true); $schema_enabled = isset($schema_data['enabled']) ? $schema_data['enabled'] : false; $schema_types = isset($schema_data['types']) ? $schema_data['types'] : []; // Get validation errors if any $validation_errors = get_post_meta($post->ID, '_metasync_schema_validation_errors', true); $has_errors = !empty($validation_errors) && is_array($validation_errors); // Schema is always enabled by default $global_schema_enabled = true; // Add nonce for security wp_nonce_field('metasync_schema_markup_nonce', 'metasync_schema_markup_nonce'); ?>

Global Schema Markup is Disabled

Schema markup is disabled globally. Please enable it in General Configuration → Enable Schema for schema markup to be output on the frontend.

Validation Issues Detected

Schema Types

Add multiple schema types to enhance your content's search visibility.

$schema_type_data): ?> render_schema_type_item($index, $schema_type_data); ?>

No schema types added yet. Click "Add Schema Type" to get started.

'Article', 'FAQPage' => 'FAQ', 'product' => 'Product', 'recipe' => 'Recipe', 'Event' => 'Event', 'JobPosting' => 'Job Posting', 'Review' => 'Review', 'Course' => 'Course', 'Organization' => 'Organization', 'Person' => 'Person', 'WebSite' => 'Website', 'NewsArticle' => 'News Article', 'LocalBusiness' => 'Local Business', 'HowTo' => 'How-To', 'VideoObject' => 'Video Object' ]; $display_name = isset($schema_type_names[$schema_type]) ? $schema_type_names[$schema_type] : ucfirst($schema_type); ?>
Schema
render_schema_fields($schema_type, $schema_fields, $index); ?>
render_article_fields($schema_fields, $index); break; case 'FAQPage': $this->render_faq_fields($schema_fields, $index); break; case 'product': $this->render_product_fields($schema_fields, $index); break; case 'recipe': $this->render_recipe_fields($schema_fields, $index); break; case 'Event': $this->render_event_fields($schema_fields, $index); break; case 'JobPosting': $this->render_jobposting_fields($schema_fields, $index); break; case 'Review': $this->render_review_fields($schema_fields, $index); break; case 'Course': $this->render_course_fields($schema_fields, $index); break; case 'Organization': $this->render_organization_fields($schema_fields, $index); break; case 'Person': $this->render_person_fields($schema_fields, $index); break; case 'WebSite': $this->render_website_fields($schema_fields, $index); break; case 'NewsArticle': $this->render_newsarticle_fields($schema_fields, $index); break; case 'LocalBusiness': $this->render_local_business_fields($schema_fields, $index); break; case 'HowTo': $this->render_howto_fields($schema_fields, $index); break; case 'VideoObject': $this->render_video_object_fields($schema_fields, $index); break; default: echo '

Select a schema type to see available fields.

'; break; } } /** * Get default values for schema fields (title, description, image) * * @param int $post_id Post ID * @return array Array with 'title', 'description', 'image' keys */ private function get_default_schema_values($post_id) { $defaults = [ 'title' => get_the_title($post_id), 'description' => '', 'image' => '' ]; // Get description: excerpt first, then trimmed content $post_excerpt = get_the_excerpt($post_id); if (!empty($post_excerpt)) { $defaults['description'] = $post_excerpt; } else { $post_content = get_post_field('post_content', $post_id); if (!empty($post_content)) { $defaults['description'] = wp_trim_words($post_content, 30); } } // Get featured image $featured_image_id = get_post_thumbnail_id($post_id); if ($featured_image_id) { $featured_image_url = wp_get_attachment_image_url($featured_image_id, 'full'); if ($featured_image_url) { $defaults['image'] = $featured_image_url; } } return $defaults; } /** * Render override default fields section * * @param array $fields Schema fields * @param int $index Schema type index * @param string $schema_type Schema type (article, product, recipe) * @param bool $include_image Whether to include image override field */ private function render_override_fields_section($fields, $index, $schema_type, $include_image = true) { global $post; $post_id = $post ? $post->ID : 0; if (!$post_id) { return; } $defaults = $this->get_default_schema_values($post_id); // Get override values - only populate with placeholders for NEW schemas (keys don't exist) // If keys exist but are empty, respect the user's choice to leave them blank if (!array_key_exists('title_override', $fields)) { // New schema - populate with placeholder $title_override = '{{post_title}}'; } else { // Existing schema - respect saved value (even if blank) $title_override = $fields['title_override']; } if (!array_key_exists('description_override', $fields)) { // New schema - populate with placeholder $description_override = '{{post_description}}'; } else { // Existing schema - respect saved value (even if blank) $description_override = $fields['description_override']; } if (!array_key_exists('image_override', $fields)) { // New schema - populate with placeholder $image_override = '{{featured_image}}'; } else { // Existing schema - respect saved value (even if blank) $image_override = $fields['image_override']; } // Use override value for image preview if provided and not a placeholder, otherwise use default $image_placeholder = ($image_override && !$this->is_placeholder($image_override)) ? $image_override : $defaults['image']; // Check if any overrides are active (not empty and not placeholders) $has_overrides = (!empty($title_override) && !$this->is_placeholder($title_override)) || (!empty($description_override) && !$this->is_placeholder($description_override)) || (!empty($image_override) && !$this->is_placeholder($image_override)); ?>

Override Default Fields Active

render_override_fields_section($fields, $index, 'article', true); ?>

Article Information

Optional fields to enhance your article schema.

The name of the organization that published this article.

Logo Preview

Logo of the organization (recommended for better rich results).

'', 'answer' => '']]; ?>

FAQ Questions & Answers

Add questions and answers for your FAQ page. Both fields are required for each FAQ item.

$item): ?>
render_override_fields_section($fields, $index, 'product', true); ?>

Product Information

Fill in the product details below. Fields marked with * are required.

Required: Enter the product price (must be greater than 0).

render_override_fields_section($fields, $index, 'recipe', true); ?>

Recipe Information

Optional fields to enhance your recipe schema.

Business Information

Fill in the business details below. Fields marked with * are required.

Address Details

Geo Coordinates

Opening Hours

Add opening hours for the business.

$hours): ?>
'', 'image' => '']]; ?>
render_override_fields_section($fields, $index, 'howto', true); ?>

How-To Information

Fill in the how-to details below. At least one step with instructions is required.

Steps

Add at least one step with instructions. Fields marked with * are required.

$step): ?>

Video Information

Fill in the video details below. Fields marked with * are required.

Direct URL to the video file.

URL for embedding the video (e.g., YouTube embed URL).

common->sanitize_array($_POST['schema_markup']); $schema_data['enabled'] = isset($post_data['enabled']) ? (bool)$post_data['enabled'] : false; $schema_data['types'] = []; // Process multiple schema types and ensure uniqueness if (isset($post_data['types']) && is_array($post_data['types'])) { $seen_types = []; foreach ($post_data['types'] as $type_data) { if (!empty($type_data['type'])) { $schema_type = sanitize_text_field($type_data['type']); // Skip if this schema type already exists (keep first occurrence) if (in_array($schema_type, $seen_types)) { continue; } $schema_fields = isset($type_data['fields']) ? $this->sanitize_schema_fields($type_data['fields'], $schema_type) : []; $schema_data['types'][] = [ 'type' => $schema_type, 'fields' => $schema_fields ]; // Mark this type as seen $seen_types[] = $schema_type; } } } } // If checkbox is unchecked, preserve existing schema data but mark as disabled if (empty($schema_data['enabled'])) { if (!empty($existing_schema_data) && !empty($existing_schema_data['types'])) { // Preserve existing schema types but mark as disabled $schema_data['enabled'] = false; $schema_data['types'] = $existing_schema_data['types']; } } // Delete any existing validation errors delete_post_meta($post_id, '_metasync_schema_validation_errors'); // Save the meta (only validate if enabled) if (!empty($schema_data['enabled']) && !empty($schema_data['types'])) { // Validate schema requirements for all types $all_validation_errors = []; foreach ($schema_data['types'] as $schema_type_data) { $validation_errors = $this->validate_schema_requirements($post_id, $schema_type_data['type'], $schema_type_data['fields']); if (!empty($validation_errors)) { $all_validation_errors = array_merge($all_validation_errors, $validation_errors); } } if (!empty($all_validation_errors)) { // Store validation errors as post meta update_post_meta($post_id, '_metasync_schema_validation_errors', $all_validation_errors); // Set a transient to show admin notice immediately after save set_transient('metasync_schema_validation_error_' . $post_id, $all_validation_errors, 45); } } // Save the schema data update_post_meta($post_id, 'metasync_schema_markup', $schema_data); // Cross-plugin sync for each schema type if (!empty($schema_data['enabled']) && !empty($schema_data['types'])) { foreach ($schema_data['types'] as $schema_type_data) { $this->sync_schema_to_seo_plugins($post_id, $schema_type_data['type'], $schema_type_data['fields']); } } } /** * Validate schema requirements based on type * * @param int $post_id The post ID * @param string $type Schema type * @param array $fields Schema fields * @return array Array of validation errors (empty if valid) */ public function validate_schema_requirements($post_id, $type, $fields) { $errors = []; switch ($type) { case 'article': $errors = $this->validate_article_schema($post_id, $fields); break; // Add more schema type validations here in the future case 'product': $errors = $this->validate_product_schema($post_id, $fields); break; case 'recipe': $errors = $this->validate_recipe_schema($post_id, $fields); break; case 'FAQPage': $errors = $this->validate_faq_schema($post_id, $fields); break; case 'Event': $errors = $this->validate_event_schema($post_id, $fields); break; case 'JobPosting': $errors = $this->validate_jobposting_schema($post_id, $fields); break; case 'Review': $errors = $this->validate_review_schema($post_id, $fields); break; case 'Course': $errors = $this->validate_course_schema($post_id, $fields); break; case 'Organization': $errors = $this->validate_organization_schema($post_id, $fields); break; case 'Person': $errors = $this->validate_person_schema($post_id, $fields); break; case 'WebSite': $errors = $this->validate_website_schema($post_id, $fields); break; case 'NewsArticle': $errors = $this->validate_newsarticle_schema($post_id, $fields); break; case 'LocalBusiness': $errors = $this->validate_local_business_schema($post_id, $fields); break; case 'HowTo': $errors = $this->validate_howto_schema($post_id, $fields); break; case 'VideoObject': $errors = $this->validate_video_object_schema($post_id, $fields); break; } return $errors; } /** * Validate Article schema requirements * Article requires: Headline, Description, and Image * * @param int $post_id The post ID * @param array $fields Schema fields * @return array Array of validation errors */ private function validate_article_schema($post_id, $fields) { $errors = []; // Get effective values (override if provided, otherwise defaults) $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id); $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id); $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id); // 1. Validate Headline (Title) if (empty($effective_title) || trim($effective_title) === '') { $errors[] = [ 'field' => 'headline', 'message' => 'Article schema requires a Headline. Please add a post title or override it in the Override Default Fields section.' ]; } // 2. Validate Description if (empty($effective_description) || trim($effective_description) === '') { $errors[] = [ 'field' => 'description', 'message' => 'Article schema requires a Description. Please add post content, an excerpt, or override it in the Override Default Fields section.' ]; } // 3. Validate Image if (empty($effective_image)) { $errors[] = [ 'field' => 'image', 'message' => 'Article schema requires an Image. Please set a featured image for this post or override it in the Override Default Fields section.' ]; } return $errors; } /** * Validate Product schema requirements * Product requires: Name, Description, Image, and Price * * @param int $post_id The post ID * @param array $fields Schema fields * @return array Array of validation errors */ private function validate_product_schema($post_id, $fields) { $errors = []; // Get effective values (override if provided, otherwise defaults) $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id); $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id); $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id); // 1. Validate Name (Title) if (empty($effective_title) || trim($effective_title) === '') { $errors[] = [ 'field' => 'name', 'message' => 'Product schema requires a Name. Please add a post title or override it in the Override Default Fields section.' ]; } // 2. Validate Description if (empty($effective_description) || trim($effective_description) === '') { $errors[] = [ 'field' => 'description', 'message' => 'Product schema requires a Description. Please add post content, an excerpt, or override it in the Override Default Fields section.' ]; } // 3. Validate Image if (empty($effective_image)) { $errors[] = [ 'field' => 'image', 'message' => 'Product schema requires an Image. Please set a featured image for this post or override it in the Override Default Fields section.' ]; } // 4. Validate Price if (empty($fields['price']) || floatval($fields['price']) <= 0) { $errors[] = [ 'field' => 'price', 'message' => 'Product schema requires a valid Price. Please enter a price greater than 0.' ]; } return $errors; } /** * Validate Recipe schema requirements * Recipe requires: Name, Description, and Image * * @param int $post_id The post ID * @param array $fields Schema fields * @return array Array of validation errors */ private function validate_recipe_schema($post_id, $fields) { $errors = []; // Get effective values (override if provided, otherwise defaults) $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id); $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id); $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id); // 1. Validate Name (Title) if (empty($effective_title) || trim($effective_title) === '') { $errors[] = [ 'field' => 'name', 'message' => 'Recipe schema requires a Name. Please add a post title or override it in the Override Default Fields section.' ]; } // 2. Validate Description if (empty($effective_description) || trim($effective_description) === '') { $errors[] = [ 'field' => 'description', 'message' => 'Recipe schema requires a Description. Please add post content, an excerpt, or override it in the Override Default Fields section.' ]; } // 3. Validate Image if (empty($effective_image)) { $errors[] = [ 'field' => 'image', 'message' => 'Recipe schema requires an Image. Please set a featured image for this post or override it in the Override Default Fields section.' ]; } return $errors; } /** * Validate FAQ schema requirements * FAQ requires at least one Q&A pair with non-empty question and answer * * @param int $post_id The post ID * @param array $fields Schema fields * @return array Array of validation errors */ private function validate_faq_schema($post_id, $fields) { $errors = []; // Check if FAQ items exist if (empty($fields['faq_items']) || !is_array($fields['faq_items'])) { $errors[] = [ 'field' => 'faq_items', 'message' => 'FAQ schema requires at least one Question & Answer pair. Please add at least one FAQ item.' ]; return $errors; } // Validate each FAQ item $has_valid_item = false; foreach ($fields['faq_items'] as $index => $item) { $question = isset($item['question']) ? trim($item['question']) : ''; $answer = isset($item['answer']) ? trim($item['answer']) : ''; // Check if both question and answer are provided if (!empty($question) && !empty($answer)) { $has_valid_item = true; } elseif (!empty($question) || !empty($answer)) { // Partial entry - one is filled but not the other $item_number = $index + 1; if (empty($question)) { $errors[] = [ 'field' => 'faq_items', 'message' => "FAQ item #{$item_number}: Question cannot be left blank." ]; } if (empty($answer)) { $errors[] = [ 'field' => 'faq_items', 'message' => "FAQ item #{$item_number}: Answer cannot be left blank." ]; } } } // Check if at least one valid Q&A pair exists if (!$has_valid_item) { $errors[] = [ 'field' => 'faq_items', 'message' => 'FAQ schema requires at least one complete Question & Answer pair. Please fill in both question and answer for at least one FAQ item.' ]; } return $errors; } /** * Validate LocalBusiness schema requirements */ private function validate_local_business_schema($post_id, $fields) { $errors = []; if (empty($fields['business_name']) || trim($fields['business_name']) === '') { $errors[] = [ 'field' => 'business_name', 'message' => 'LocalBusiness schema requires a Business Name.' ]; } if (empty($fields['street_address']) || trim($fields['street_address']) === '') { $errors[] = [ 'field' => 'street_address', 'message' => 'LocalBusiness schema requires a Street Address.' ]; } return $errors; } /** * Validate HowTo schema requirements */ private function validate_howto_schema($post_id, $fields) { $errors = []; // Validate override fields (title, description, image) $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id); $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id); $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id); if (empty($effective_title) || trim($effective_title) === '') { $errors[] = [ 'field' => 'name', 'message' => 'How-To schema requires a Name. Please add a post title or override it in the Override Default Fields section.' ]; } if (empty($effective_description) || trim($effective_description) === '') { $errors[] = [ 'field' => 'description', 'message' => 'How-To schema requires a Description. Please add post content, an excerpt, or override it in the Override Default Fields section.' ]; } if (empty($effective_image)) { $errors[] = [ 'field' => 'image', 'message' => 'How-To schema requires an Image. Please set a featured image for this post or override it in the Override Default Fields section.' ]; } // Validate at least one step with non-empty instructions $has_valid_step = false; if (!empty($fields['steps']) && is_array($fields['steps'])) { foreach ($fields['steps'] as $step) { if (!empty($step['instructions']) && trim($step['instructions']) !== '') { $has_valid_step = true; break; } } } if (!$has_valid_step) { $errors[] = [ 'field' => 'steps', 'message' => 'How-To schema requires at least one Step with instructions.' ]; } return $errors; } /** * Validate VideoObject schema requirements */ private function validate_video_object_schema($post_id, $fields) { $errors = []; if (empty($fields['video_name']) || trim($fields['video_name']) === '') { $errors[] = [ 'field' => 'video_name', 'message' => 'VideoObject schema requires a Video Name.' ]; } if (empty($fields['video_description']) || trim($fields['video_description']) === '') { $errors[] = [ 'field' => 'video_description', 'message' => 'VideoObject schema requires a Video Description.' ]; } if (empty($fields['thumbnail_url']) || trim($fields['thumbnail_url']) === '') { $errors[] = [ 'field' => 'thumbnail_url', 'message' => 'VideoObject schema requires a Thumbnail URL.' ]; } if (empty($fields['upload_date']) || trim($fields['upload_date']) === '') { $errors[] = [ 'field' => 'upload_date', 'message' => 'VideoObject schema requires an Upload Date.' ]; } return $errors; } /** * Display schema validation notices in admin */ public function display_schema_validation_notices() { // Only show on post edit screens if ( ! function_exists( 'get_current_screen' ) ) { return; } $screen = get_current_screen(); if (!$screen || ($screen->base !== 'post' && $screen->base !== 'post-new')) { return; } // Get current post ID global $post; if (!$post || !isset($post->ID)) { return; } $post_id = $post->ID; // Check for validation errors from transient (immediately after save) $errors = get_transient('metasync_schema_validation_error_' . $post_id); // If no transient, check post meta (for persistent errors) if ($errors === false) { $errors = get_post_meta($post_id, '_metasync_schema_validation_errors', true); } else { // Delete the transient after displaying once delete_transient('metasync_schema_validation_error_' . $post_id); } if (empty($errors) || !is_array($errors)) { return; } // Get schema type for context $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true); $schema_type = isset($schema_data['type']) ? ucfirst($schema_data['type']) : 'Schema'; ?>

⚠️ Schema Validation Errors

The following required fields are missing:

Please fix these issues to ensure your schema markup is valid and can be properly indexed by search engines.

is_placeholder($image_override_raw)) { // Store placeholder as-is $sanitized['image_override'] = $image_override_raw; } else { // Sanitize as URL only if it's not a placeholder $sanitized['image_override'] = !empty($image_override_raw) ? esc_url_raw($image_override_raw) : ''; } switch ($type) { case 'article': $sanitized['organization_name'] = isset($fields['organization_name']) ? sanitize_text_field($fields['organization_name']) : ''; $sanitized['organization_logo'] = isset($fields['organization_logo']) ? esc_url_raw($fields['organization_logo']) : ''; break; case 'FAQPage': // FAQ doesn't use override fields, so remove them unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['faq_items'] = []; if (isset($fields['faq_items']) && is_array($fields['faq_items'])) { foreach ($fields['faq_items'] as $item) { if (!empty($item['question']) && !empty($item['answer'])) { $sanitized['faq_items'][] = [ 'question' => sanitize_text_field($item['question']), 'answer' => sanitize_textarea_field($item['answer']) ]; } } } break; case 'product': $sanitized['sku'] = isset($fields['sku']) ? sanitize_text_field($fields['sku']) : ''; $sanitized['brand'] = isset($fields['brand']) ? sanitize_text_field($fields['brand']) : ''; $sanitized['price'] = isset($fields['price']) ? floatval($fields['price']) : 0; $sanitized['currency'] = isset($fields['currency']) ? sanitize_text_field($fields['currency']) : 'USD'; $sanitized['availability'] = isset($fields['availability']) ? sanitize_text_field($fields['availability']) : 'InStock'; $sanitized['condition'] = isset($fields['condition']) ? sanitize_text_field($fields['condition']) : 'NewCondition'; break; case 'recipe': $sanitized['yield'] = isset($fields['yield']) ? sanitize_text_field($fields['yield']) : ''; $sanitized['ingredients'] = isset($fields['ingredients']) ? array_map('sanitize_text_field', array_filter($fields['ingredients'])) : []; $sanitized['instructions'] = isset($fields['instructions']) ? array_map('sanitize_textarea_field', array_filter($fields['instructions'])) : []; $sanitized['prep_time'] = isset($fields['prep_time']) ? intval($fields['prep_time']) : 0; $sanitized['cook_time'] = isset($fields['cook_time']) ? intval($fields['cook_time']) : 0; $sanitized['total_time'] = isset($fields['total_time']) ? intval($fields['total_time']) : 0; $sanitized['calories'] = isset($fields['calories']) ? intval($fields['calories']) : 0; break; case 'Event': unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : ''; $sanitized['startDate'] = isset($fields['startDate']) ? sanitize_text_field($fields['startDate']) : ''; $sanitized['endDate'] = isset($fields['endDate']) ? sanitize_text_field($fields['endDate']) : ''; $sanitized['eventStatus'] = isset($fields['eventStatus']) ? sanitize_text_field($fields['eventStatus']) : 'EventScheduled'; $sanitized['eventAttendanceMode'] = isset($fields['eventAttendanceMode']) ? sanitize_text_field($fields['eventAttendanceMode']) : 'OfflineEventAttendanceMode'; $sanitized['location_name'] = isset($fields['location_name']) ? sanitize_text_field($fields['location_name']) : ''; $sanitized['location_address'] = isset($fields['location_address']) ? sanitize_text_field($fields['location_address']) : ''; $sanitized['organizer_name'] = isset($fields['organizer_name']) ? sanitize_text_field($fields['organizer_name']) : ''; $sanitized['organizer_url'] = isset($fields['organizer_url']) ? esc_url_raw($fields['organizer_url']) : ''; $sanitized['offer_price'] = isset($fields['offer_price']) ? sanitize_text_field($fields['offer_price']) : ''; $sanitized['offer_priceCurrency'] = isset($fields['offer_priceCurrency']) ? sanitize_text_field($fields['offer_priceCurrency']) : 'USD'; $sanitized['offer_availability'] = isset($fields['offer_availability']) ? sanitize_text_field($fields['offer_availability']) : 'InStock'; $sanitized['offer_url'] = isset($fields['offer_url']) ? esc_url_raw($fields['offer_url']) : ''; $sanitized['image'] = isset($fields['image']) ? esc_url_raw($fields['image']) : ''; $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : ''; break; case 'JobPosting': unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['title'] = isset($fields['title']) ? sanitize_text_field($fields['title']) : ''; $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : ''; $sanitized['datePosted'] = isset($fields['datePosted']) ? sanitize_text_field($fields['datePosted']) : ''; $sanitized['validThrough'] = isset($fields['validThrough']) ? sanitize_text_field($fields['validThrough']) : ''; $sanitized['employmentType'] = isset($fields['employmentType']) ? sanitize_text_field($fields['employmentType']) : 'FULL_TIME'; $sanitized['hiringOrganization_name'] = isset($fields['hiringOrganization_name']) ? sanitize_text_field($fields['hiringOrganization_name']) : ''; $sanitized['hiringOrganization_sameAs'] = isset($fields['hiringOrganization_sameAs']) ? esc_url_raw($fields['hiringOrganization_sameAs']) : ''; $sanitized['hiringOrganization_logo'] = isset($fields['hiringOrganization_logo']) ? esc_url_raw($fields['hiringOrganization_logo']) : ''; $sanitized['jobLocation_address'] = isset($fields['jobLocation_address']) ? sanitize_text_field($fields['jobLocation_address']) : ''; $sanitized['baseSalary_currency'] = isset($fields['baseSalary_currency']) ? sanitize_text_field($fields['baseSalary_currency']) : ''; $sanitized['baseSalary_value'] = isset($fields['baseSalary_value']) ? floatval($fields['baseSalary_value']) : 0; $sanitized['baseSalary_unitText'] = isset($fields['baseSalary_unitText']) ? sanitize_text_field($fields['baseSalary_unitText']) : 'YEAR'; break; case 'Review': unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['itemReviewed_name'] = isset($fields['itemReviewed_name']) ? sanitize_text_field($fields['itemReviewed_name']) : ''; $sanitized['reviewRating_ratingValue'] = isset($fields['reviewRating_ratingValue']) ? floatval($fields['reviewRating_ratingValue']) : 0; $sanitized['reviewRating_bestRating'] = isset($fields['reviewRating_bestRating']) ? floatval($fields['reviewRating_bestRating']) : 5; $sanitized['author_name'] = isset($fields['author_name']) ? sanitize_text_field($fields['author_name']) : ''; $sanitized['reviewBody'] = isset($fields['reviewBody']) ? sanitize_textarea_field($fields['reviewBody']) : ''; break; case 'Course': unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : ''; $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : ''; $sanitized['provider_name'] = isset($fields['provider_name']) ? sanitize_text_field($fields['provider_name']) : ''; $sanitized['provider_sameAs'] = isset($fields['provider_sameAs']) ? esc_url_raw($fields['provider_sameAs']) : ''; $sanitized['courseInstance_courseMode'] = isset($fields['courseInstance_courseMode']) ? sanitize_text_field($fields['courseInstance_courseMode']) : 'online'; $sanitized['courseInstance_instructor_name'] = isset($fields['courseInstance_instructor_name']) ? sanitize_text_field($fields['courseInstance_instructor_name']) : ''; break; case 'Organization': unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : ''; $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : ''; $sanitized['logo'] = isset($fields['logo']) ? esc_url_raw($fields['logo']) : ''; $sanitized['contactPoint_telephone'] = isset($fields['contactPoint_telephone']) ? sanitize_text_field($fields['contactPoint_telephone']) : ''; $sanitized['contactPoint_contactType'] = isset($fields['contactPoint_contactType']) ? sanitize_text_field($fields['contactPoint_contactType']) : ''; $sanitized['sameAs'] = isset($fields['sameAs']) ? sanitize_textarea_field($fields['sameAs']) : ''; break; case 'Person': unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : ''; $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : ''; $sanitized['jobTitle'] = isset($fields['jobTitle']) ? sanitize_text_field($fields['jobTitle']) : ''; $sanitized['worksFor'] = isset($fields['worksFor']) ? sanitize_text_field($fields['worksFor']) : ''; $sanitized['email'] = isset($fields['email']) ? sanitize_email($fields['email']) : ''; $sanitized['image'] = isset($fields['image']) ? esc_url_raw($fields['image']) : ''; $sanitized['sameAs'] = isset($fields['sameAs']) ? sanitize_textarea_field($fields['sameAs']) : ''; break; case 'WebSite': unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : ''; $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : ''; $sanitized['searchbox_enabled'] = isset($fields['searchbox_enabled']) ? (bool) $fields['searchbox_enabled'] : false; $sanitized['searchbox_query_input'] = isset($fields['searchbox_query_input']) ? sanitize_text_field($fields['searchbox_query_input']) : ''; break; case 'NewsArticle': $sanitized['organization_name'] = isset($fields['organization_name']) ? sanitize_text_field($fields['organization_name']) : ''; $sanitized['organization_logo'] = isset($fields['organization_logo']) ? esc_url_raw($fields['organization_logo']) : ''; break; case 'LocalBusiness': // Self-managed type, remove override fields unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['business_name'] = isset($fields['business_name']) ? sanitize_text_field($fields['business_name']) : ''; $sanitized['street_address'] = isset($fields['street_address']) ? sanitize_text_field($fields['street_address']) : ''; $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : ''; $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : ''; $sanitized['telephone'] = isset($fields['telephone']) ? sanitize_text_field($fields['telephone']) : ''; $sanitized['price_range'] = isset($fields['price_range']) ? sanitize_text_field($fields['price_range']) : ''; $sanitized['image'] = isset($fields['image']) ? esc_url_raw($fields['image']) : ''; $sanitized['city'] = isset($fields['city']) ? sanitize_text_field($fields['city']) : ''; $sanitized['state'] = isset($fields['state']) ? sanitize_text_field($fields['state']) : ''; $sanitized['postal_code'] = isset($fields['postal_code']) ? sanitize_text_field($fields['postal_code']) : ''; $sanitized['country'] = isset($fields['country']) ? sanitize_text_field($fields['country']) : ''; $sanitized['latitude'] = isset($fields['latitude']) ? sanitize_text_field($fields['latitude']) : ''; $sanitized['longitude'] = isset($fields['longitude']) ? sanitize_text_field($fields['longitude']) : ''; $sanitized['opening_hours'] = []; if (isset($fields['opening_hours']) && is_array($fields['opening_hours'])) { foreach ($fields['opening_hours'] as $hours) { if (!empty($hours['day'])) { $sanitized['opening_hours'][] = [ 'day' => sanitize_text_field($hours['day']), 'open' => sanitize_text_field($hours['open']), 'close' => sanitize_text_field($hours['close']), ]; } } } break; case 'HowTo': $sanitized['total_time'] = isset($fields['total_time']) ? intval($fields['total_time']) : 0; $sanitized['estimated_cost'] = isset($fields['estimated_cost']) ? sanitize_text_field($fields['estimated_cost']) : ''; $sanitized['supplies'] = isset($fields['supplies']) ? array_map('sanitize_text_field', array_filter($fields['supplies'])) : []; $sanitized['tools'] = isset($fields['tools']) ? array_map('sanitize_text_field', array_filter($fields['tools'])) : []; $sanitized['steps'] = []; if (isset($fields['steps']) && is_array($fields['steps'])) { foreach ($fields['steps'] as $step) { $sanitized['steps'][] = [ 'instructions' => isset($step['instructions']) ? sanitize_textarea_field($step['instructions']) : '', 'image' => isset($step['image']) ? esc_url_raw($step['image']) : '', ]; } } break; case 'VideoObject': // Self-managed type, remove override fields unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']); $sanitized['video_name'] = isset($fields['video_name']) ? sanitize_text_field($fields['video_name']) : ''; $sanitized['video_description'] = isset($fields['video_description']) ? sanitize_textarea_field($fields['video_description']) : ''; $sanitized['thumbnail_url'] = isset($fields['thumbnail_url']) ? esc_url_raw($fields['thumbnail_url']) : ''; $sanitized['upload_date'] = isset($fields['upload_date']) ? sanitize_text_field($fields['upload_date']) : ''; $sanitized['content_url'] = isset($fields['content_url']) ? esc_url_raw($fields['content_url']) : ''; $sanitized['embed_url'] = isset($fields['embed_url']) ? esc_url_raw($fields['embed_url']) : ''; $sanitized['duration'] = isset($fields['duration']) ? intval($fields['duration']) : 0; break; } return $sanitized; } /** * Output schema markup to the frontend */ public function output_schema_markup() { // Schema Markup switched off. This covers every JSON-LD source resolved // below — global Organization/WebSite nodes, the per-post meta box types, // live OTTO structured data and the DB-persisted OTTO payload. if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) { return; } $all_json_ld = []; // Holds the DB-persisted OTTO JSON-LD string when OTTO is off (output as its // own tag below). Stays null while OTTO is live to avoid the duplicate. $otto_persisted_jsonld = null; // ---- Global schema: WebSite on front page, Organization on all pages ---- $metasync_options = get_option('metasync_options', []); $schema_settings = isset($metasync_options['schema']) ? $metasync_options['schema'] : []; // Organization schema (site-wide) if (!empty($schema_settings['org_name'])) { if ($this->should_output_schema_type('Organization')) { $org_node = ['@type' => 'Organization']; $org_node['name'] = $schema_settings['org_name']; if (!empty($schema_settings['org_url'])) { $org_node['url'] = $schema_settings['org_url']; } if (!empty($schema_settings['org_logo'])) { $org_node['logo'] = [ '@type' => 'ImageObject', 'url' => $schema_settings['org_logo'], ]; } if (!empty($schema_settings['org_contact_telephone']) || !empty($schema_settings['org_contact_type'])) { $org_node['contactPoint'] = ['@type' => 'ContactPoint']; if (!empty($schema_settings['org_contact_telephone'])) { $org_node['contactPoint']['telephone'] = $schema_settings['org_contact_telephone']; } if (!empty($schema_settings['org_contact_type'])) { $org_node['contactPoint']['contactType'] = $schema_settings['org_contact_type']; } } if (!empty($schema_settings['org_same_as'])) { $urls = array_filter(array_map('trim', explode("\n", $schema_settings['org_same_as']))); if (!empty($urls)) { $org_node['sameAs'] = $urls; } } $all_json_ld[] = $org_node; } } // WebSite schema (front page only) if (is_front_page()) { if (!empty($schema_settings['website_name']) && $this->should_output_schema_type('WebSite')) { $ws_node = ['@type' => 'WebSite']; $ws_node['name'] = $schema_settings['website_name']; $ws_node['url'] = !empty($schema_settings['website_url']) ? $schema_settings['website_url'] : home_url('/'); if (!empty($schema_settings['website_searchbox'])) { $base_url = rtrim($ws_node['url'], '/'); $ws_node['potentialAction'] = [ '@type' => 'SearchAction', 'target' => [ '@type' => 'EntryPoint', 'urlTemplate' => $base_url . '/?s={search_term_string}', ], 'query-input' => 'required name=search_term_string', ]; } $all_json_ld[] = $ws_node; } } // Person schema on author archive pages if (is_author()) { if ($this->should_output_schema_type('Person')) { $author = get_queried_object(); if ($author && is_a($author, 'WP_User')) { $person_node = [ '@type' => 'Person', 'name' => $author->display_name, 'url' => get_author_posts_url($author->ID), ]; $user_desc = get_the_author_meta('description', $author->ID); if (!empty($user_desc)) { $person_node['description'] = $user_desc; } $all_json_ld[] = $person_node; } } } // ---- Per-post schema (singular pages only) ---- if (is_singular()) { global $post; if ($post && isset($post->ID)) { $schema_data = get_post_meta($post->ID, 'metasync_schema_markup', true); $has_user_types = !empty($schema_data['enabled']) && !empty($schema_data['types']); // Track which types the global schema already outputs $global_types_output = []; if (!empty($schema_settings['org_name'])) { $global_types_output[] = 'Organization'; } if (is_front_page() && !empty($schema_settings['website_name'])) { $global_types_output[] = 'WebSite'; } // Generate JSON-LD for user-entered schema types. if ($has_user_types) { $validation_errors = get_post_meta($post->ID, '_metasync_schema_validation_errors', true); if (empty($validation_errors) || !is_array($validation_errors)) { foreach ($schema_data['types'] as $schema_type_data) { // Skip per-post types that the global schema already covers $type_key = $schema_type_data['type']; if (in_array($type_key, $global_types_output, true)) { continue; } // Skip BreadcrumbList if already injected by the breadcrumbs module. if ( $type_key === 'BreadcrumbList' && class_exists('Metasync_Breadcrumbs_Schema') && Metasync_Breadcrumbs_Schema::$breadcrumb_list_injected ) { continue; } if ($this->should_output_schema_type($type_key)) { $json_ld = $this->generate_json_ld($post, $schema_type_data); if ($json_ld) { $all_json_ld[] = $json_ld; } } } } } // OTTO-persisted JSON-LD: skip only while OTTO is live — avoid // duplicate; the persisted copy is output below when OTTO is off. // // While OTTO is enabled, schema is delivered live via `header_html_insertion` // and injected by Otto_html_class with a `data-otto="true"` marker, so merging // the DB-persisted copy into this @graph would produce duplicate JSON-LD on // every page. When OTTO is OFF there is no live injection and therefore // no duplicate risk, yet the schema is still saved in the DB — so we surface the // persisted copy as its own standalone tag after the @graph block. if ( isset($schema_data['otto_jsonld']['data']) && is_string($schema_data['otto_jsonld']['data']) && $schema_data['otto_jsonld']['data'] !== '' && (!class_exists('Metasync_Otto_Config') || !Metasync_Otto_Config::is_otto_enabled()) && !$this->active_seo_plugin_emits_schema() ) { $otto_persisted_jsonld = $schema_data['otto_jsonld']['data']; } } } // Output all schemas in a single script tag with @graph pattern if (!empty($all_json_ld)) { $final_json_ld = [ '@context' => 'https://schema.org', '@graph' => $all_json_ld, ]; $graph_json = metasync_safe_json_ld_encode($final_json_ld, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); if (is_string($graph_json) && $graph_json !== '') { echo '' . "\n"; } } // OTTO is off: output the DB-persisted OTTO JSON-LD as its own standalone tag. // Echoed without a json_decode/encode round-trip to match how OTTO injects it live // and to avoid the Unicode-corruption noted in. The only transform applied is // escaping `` inside a string value cannot break // out of the tag (page corruption / XSS). `\/` is a valid JSON escape for `/`, so the // consumer reads identical content and the persisted bytes are otherwise preserved. // The data field is a full JSON-LD document or node, so it is emitted as a separate // script rather than merged into the @graph above. Stays null while OTTO is live. // // NOTE (per-page scope): the OTTO gate above uses Metasync_Otto_Config::is_otto_enabled(), // which is global (UUID configured) rather than per-request. On a site where OTTO is // enabled globally but did not inject on this specific page (e.g. excluded/uncrawled URL), // the persisted copy is intentionally still skipped — OTTO owns schema delivery while it // is enabled, and emitting here cannot reliably know whether OTTO injected this request // without risking the duplicate. Tracked as a known limitation. if (!empty($otto_persisted_jsonld)) { echo '` breakout; valid JSON, content unchanged. echo metasync_escape_json_ld_for_script($otto_persisted_jsonld); // phpcs:ignore WordPress.Security.EscapeOutput -- persisted JSON-LD string, only `' . "\n"; } } /** * (AC#3): Whether an active third-party SEO plugin will already emit the schema. * * When OTTO syncs, it cross-writes the structured data into the active SEO plugin * (Yoast / Rank Math / AIOSEO), which then renders it from its own storage. If such a * plugin is active, also emitting our persisted copy would duplicate the JSON-LD — so the * caller suppresses the standalone OTTO tag and lets the SEO plugin own the output. * * Returns false when the conflict handler is unavailable (no third-party integration in * play), preserving the OTTO-off / no-SEO-plugin path that this feature exists for. * * @return bool */ protected function active_seo_plugin_emits_schema() { if (!class_exists('Metasync_Seo_Conflict_Handler')) { return false; } return Metasync_Seo_Conflict_Handler::get_instance()->has_active_seo_plugin(); } /** * Replace placeholders with actual dynamic values * * @param string $value Value that may contain placeholders * @param int $post_id Post ID * @return string Value with placeholders replaced */ private function replace_placeholders($value, $post_id) { if (empty($value)) { return $value; } $defaults = $this->get_default_schema_values($post_id); // Replace placeholders with actual values $value = str_replace('{{post_title}}', $defaults['title'], $value); $value = str_replace('{{post_description}}', $defaults['description'], $value); $value = str_replace('{{featured_image}}', $defaults['image'], $value); return $value; } /** * Check if a value is a placeholder (should be treated as empty/default) * * @param string $value Value to check * @return bool True if value is a placeholder */ private function is_placeholder($value) { $placeholders = ['{{post_title}}', '{{post_description}}', '{{featured_image}}']; return in_array(trim($value), $placeholders, true); } /** * Get effective schema value (override if provided, otherwise default) * Replaces placeholders with actual dynamic values during JSON generation * * @param string $field_name Field name (title_override, description_override, image_override) * @param array $fields Schema fields array * @param int $post_id Post ID * @return string Effective value with placeholders replaced */ private function get_effective_schema_value($field_name, $fields, $post_id) { // Get override value from database $override_key = $field_name; $override_value = isset($fields[$override_key]) ? trim($fields[$override_key]) : ''; // If override value is empty (blank field), return empty // This will trigger validation error - user must either fill it or use placeholder if (empty($override_value)) { return ''; } // If override value is provided (including placeholders), replace placeholders with actual data return $this->replace_placeholders($override_value, $post_id); } /** * Generate JSON-LD based on schema type */ private function generate_json_ld($post, $schema_type_data) { $type = $schema_type_data['type']; $fields = $schema_type_data['fields']; // Build base JSON-LD without @context (will be added at root level) // Map type keys to proper Schema.org @type values $type_map = [ 'article' => 'Article', 'FAQPage' => 'FAQPage', 'product' => 'Product', 'recipe' => 'Recipe', 'Event' => 'Event', 'JobPosting' => 'JobPosting', 'Review' => 'Review', 'Course' => 'Course', 'Organization' => 'Organization', 'Person' => 'Person', 'WebSite' => 'WebSite', 'NewsArticle' => 'NewsArticle', 'LocalBusiness' => 'LocalBusiness', 'HowTo' => 'HowTo', 'VideoObject' => 'VideoObject', ]; $json_ld = [ '@type' => isset($type_map[$type]) ? $type_map[$type] : ucfirst($type) ]; // Types that manage their own fields (no override fields pattern) $self_managed_types = ['Event', 'JobPosting', 'Review', 'Course', 'Organization', 'Person', 'WebSite', 'LocalBusiness', 'VideoObject']; if (!in_array($type, $self_managed_types)) { // Get effective values (override if provided, otherwise defaults) $effective_title = $this->get_effective_schema_value('title_override', $fields, $post->ID); $effective_description = $this->get_effective_schema_value('description_override', $fields, $post->ID); $effective_image = $this->get_effective_schema_value('image_override', $fields, $post->ID); // For articles/NewsArticle, don't add 'name' and 'url' as we'll use 'headline' and 'mainEntityOfPage' instead // For FAQ, we only need the schema type and mainEntity if ($type !== 'article' && $type !== 'FAQPage' && $type !== 'NewsArticle') { $json_ld['name'] = $effective_title; $json_ld['url'] = get_permalink($post->ID); $json_ld['description'] = $effective_description; // Add images - use override if provided, otherwise get from featured image if (!empty($effective_image)) { $json_ld['image'] = $effective_image; } else { $images = $this->get_post_images($post->ID); if (!empty($images)) { $json_ld['image'] = count($images) === 1 ? $images[0] : $images; } } } elseif ($type === 'article' || $type === 'NewsArticle') { $json_ld['description'] = $effective_description; // Add images - use override if provided, otherwise get from featured image if (!empty($effective_image)) { $json_ld['image'] = $effective_image; } else { $images = $this->get_post_images($post->ID); if (!empty($images)) { $json_ld['image'] = count($images) === 1 ? $images[0] : $images; } } } } switch ($type) { case 'article': $json_ld = array_merge($json_ld, $this->generate_article_json_ld($fields, $post)); break; case 'FAQPage': $json_ld = array_merge($json_ld, $this->generate_faq_json_ld($fields)); break; case 'product': $json_ld = array_merge($json_ld, $this->generate_product_json_ld($fields)); break; case 'recipe': $json_ld = array_merge($json_ld, $this->generate_recipe_json_ld($fields)); break; case 'Event': $json_ld = array_merge($json_ld, $this->generate_event_json_ld($fields, $post)); break; case 'JobPosting': $json_ld = array_merge($json_ld, $this->generate_jobposting_json_ld($fields, $post)); break; case 'Review': $json_ld = array_merge($json_ld, $this->generate_review_json_ld($fields, $post)); break; case 'Course': $json_ld = array_merge($json_ld, $this->generate_course_json_ld($fields, $post)); break; case 'Organization': $json_ld = array_merge($json_ld, $this->generate_organization_json_ld($fields, $post)); break; case 'Person': $json_ld = array_merge($json_ld, $this->generate_person_json_ld($fields, $post)); break; case 'WebSite': $json_ld = array_merge($json_ld, $this->generate_website_json_ld($fields, $post)); break; case 'NewsArticle': $json_ld = array_merge($json_ld, $this->generate_newsarticle_json_ld($fields, $post)); break; case 'LocalBusiness': $local_business_data = $this->generate_local_business_json_ld($fields); if ($local_business_data === false) { return null; } $json_ld = array_merge($json_ld, $local_business_data); break; case 'HowTo': $json_ld = array_merge($json_ld, $this->generate_howto_json_ld($fields)); break; case 'VideoObject': $json_ld = array_merge($json_ld, $this->generate_video_object_json_ld($fields)); break; } return $json_ld; } /** * Generate Article JSON-LD */ private function generate_article_json_ld($fields, $post) { $article_data = []; // Add headline (required by Google) - use override if provided $effective_title = $this->get_effective_schema_value('title_override', $fields, $post->ID); $article_data['headline'] = $effective_title; // Add mainEntityOfPage instead of url (Google's standard for articles) $article_data['mainEntityOfPage'] = [ '@type' => 'WebPage', '@id' => get_permalink($post->ID) ]; // Add datePublished (required by Google) $article_data['datePublished'] = get_the_date('c', $post->ID); // Add dateModified (required by Google) $article_data['dateModified'] = get_the_modified_date('c', $post->ID); // Add author information if (!empty($fields['organization_name'])) { // Organization as author $article_data['author'] = [ '@type' => 'Organization', 'name' => $fields['organization_name'] ]; // Add organization logo if provided if (!empty($fields['organization_logo'])) { $article_data['author']['logo'] = [ '@type' => 'ImageObject', 'url' => $fields['organization_logo'] ]; } } else { // Fallback to WordPress post author $author_id = $post->post_author; $author_name = get_the_author_meta('display_name', $author_id); $author_url = get_author_posts_url($author_id); $article_data['author'] = [ '@type' => 'Person', 'name' => $author_name, 'url' => $author_url ]; } // Add publisher information (required by Google) if (!empty($fields['organization_name'])) { $article_data['publisher'] = [ '@type' => 'Organization', 'name' => $fields['organization_name'] ]; // Add organization logo if provided (required for publisher) if (!empty($fields['organization_logo'])) { $article_data['publisher']['logo'] = [ '@type' => 'ImageObject', 'url' => $fields['organization_logo'] ]; } } return $article_data; } /** * Generate FAQ JSON-LD */ private function generate_faq_json_ld($fields) { $faq_data = []; // Add FAQ mainEntity if (!empty($fields['faq_items'])) { $faq_data['mainEntity'] = []; foreach ($fields['faq_items'] as $item) { $faq_data['mainEntity'][] = [ '@type' => 'Question', 'name' => $item['question'], 'acceptedAnswer' => [ '@type' => 'Answer', 'text' => $item['answer'] ] ]; } } return $faq_data; } /** * Generate Product JSON-LD */ private function generate_product_json_ld($fields) { $metasync_options = get_option('metasync_options', []); $override_wc = !empty($metasync_options['schema']['override_woocommerce_schema']); $wc_active = class_exists('WooCommerce'); // When WooCommerce is active and override is OFF, suppress MetaSync Product // schema entirely — let WooCommerce handle its own Product structured data. if ($wc_active && !$override_wc) { return []; } $product_data = []; if (!empty($fields['sku'])) { $product_data['sku'] = $fields['sku']; } if (!empty($fields['brand'])) { $product_data['brand'] = [ '@type' => 'Brand', 'name' => $fields['brand'] ]; } if (!empty($fields['price'])) { $product_data['offers'] = [ '@type' => 'Offer', 'price' => $fields['price'], 'priceCurrency' => $fields['currency'] ?? 'USD', 'availability' => 'https://schema.org/' . ($fields['availability'] ?? 'InStock'), 'itemCondition' => 'https://schema.org/' . ($fields['condition'] ?? 'NewCondition') ]; } // When WooCommerce is active AND override is ON, enrich with WC product data if ($wc_active && $override_wc && function_exists('wc_get_product')) { global $post; $wc_post_id = isset($post->ID) ? $post->ID : 0; $product = wc_get_product($wc_post_id); if ($product && is_a($product, 'WC_Product')) { // Auto-fill price from WC (overwrites manual fields with live data) $wc_price = $product->get_price(); if (!empty($wc_price)) { if (!isset($product_data['offers'])) { $product_data['offers'] = ['@type' => 'Offer']; } $product_data['offers']['price'] = $wc_price; $product_data['offers']['priceCurrency'] = get_woocommerce_currency(); $product_data['offers']['availability'] = 'https://schema.org/' . ($product->is_in_stock() ? 'InStock' : 'OutOfStock'); } // Auto-fill SKU $wc_sku = $product->get_sku(); if (!empty($wc_sku)) { $product_data['sku'] = $wc_sku; } // AggregateRating from WooCommerce reviews $review_count = $product->get_review_count(); if ($review_count > 0) { $product_data['aggregateRating'] = [ '@type' => 'AggregateRating', 'ratingValue' => $product->get_average_rating(), 'reviewCount' => $review_count, ]; } } } return $product_data; } /** * Generate Recipe JSON-LD */ private function generate_recipe_json_ld($fields) { $recipe_data = []; if (!empty($fields['yield'])) { $recipe_data['recipeYield'] = $fields['yield']; } if (!empty($fields['ingredients'])) { $recipe_data['recipeIngredient'] = $fields['ingredients']; } if (!empty($fields['instructions'])) { $recipe_data['recipeInstructions'] = []; foreach ($fields['instructions'] as $instruction) { $recipe_data['recipeInstructions'][] = [ '@type' => 'HowToStep', 'text' => $instruction ]; } } if (!empty($fields['prep_time'])) { $recipe_data['prepTime'] = 'PT' . $fields['prep_time'] . 'M'; } if (!empty($fields['cook_time'])) { $recipe_data['cookTime'] = 'PT' . $fields['cook_time'] . 'M'; } if (!empty($fields['total_time'])) { $recipe_data['totalTime'] = 'PT' . $fields['total_time'] . 'M'; } if (!empty($fields['calories'])) { $recipe_data['nutrition'] = [ '@type' => 'NutritionInformation', 'calories' => $fields['calories'] . ' calories' ]; } return $recipe_data; } // ====================================================================== // New Schema Type: Event // ====================================================================== /** * Render Event schema fields */ private function render_event_fields($fields, $index = 0) { $name = isset($fields['name']) ? $fields['name'] : ''; $startDate = isset($fields['startDate']) ? $fields['startDate'] : ''; $endDate = isset($fields['endDate']) ? $fields['endDate'] : ''; $eventStatus = isset($fields['eventStatus']) ? $fields['eventStatus'] : 'EventScheduled'; $eventAttendanceMode = isset($fields['eventAttendanceMode']) ? $fields['eventAttendanceMode'] : 'OfflineEventAttendanceMode'; $location_name = isset($fields['location_name']) ? $fields['location_name'] : ''; $location_address = isset($fields['location_address']) ? $fields['location_address'] : ''; $organizer_name = isset($fields['organizer_name']) ? $fields['organizer_name'] : ''; $organizer_url = isset($fields['organizer_url']) ? $fields['organizer_url'] : ''; $offer_price = isset($fields['offer_price']) ? $fields['offer_price'] : ''; $offer_priceCurrency = isset($fields['offer_priceCurrency']) ? $fields['offer_priceCurrency'] : 'USD'; $offer_availability = isset($fields['offer_availability']) ? $fields['offer_availability'] : 'InStock'; $offer_url = isset($fields['offer_url']) ? $fields['offer_url'] : ''; $image = isset($fields['image']) ? $fields['image'] : ''; $description = isset($fields['description']) ? $fields['description'] : ''; ?>

Event Information

Fields marked with * are required.

'name', 'message' => 'Event schema requires a Name.' ]; } if (empty($fields['startDate']) || trim($fields['startDate']) === '') { $errors[] = [ 'field' => 'startDate', 'message' => 'Event schema requires a Start Date.' ]; } return $errors; } /** * Format an event date with the site's timezone offset for ISO 8601 */ private function format_event_date($date_string) { if (empty($date_string)) { return $date_string; } // If it already has timezone info, return as-is if (preg_match('/[+-]\d{2}:\d{2}$|Z$/', $date_string)) { return $date_string; } // Append WordPress site timezone offset $timezone = wp_timezone(); try { $dt = new \DateTime($date_string, $timezone); return $dt->format('c'); // ISO 8601 with timezone } catch (\Exception $e) { return $date_string; } } /** * Generate Event JSON-LD */ private function generate_event_json_ld($fields, $post) { $data = []; if (!empty($fields['name'])) { $data['name'] = $fields['name']; } if (!empty($fields['startDate'])) { $data['startDate'] = $this->format_event_date($fields['startDate']); } if (!empty($fields['endDate'])) { $data['endDate'] = $this->format_event_date($fields['endDate']); } if (!empty($fields['eventStatus'])) { $data['eventStatus'] = 'https://schema.org/' . $fields['eventStatus']; } if (!empty($fields['eventAttendanceMode'])) { $data['eventAttendanceMode'] = 'https://schema.org/' . $fields['eventAttendanceMode']; } if (!empty($fields['description'])) { $data['description'] = $fields['description']; } if (!empty($fields['image'])) { $data['image'] = $fields['image']; } if (!empty($fields['location_name']) || !empty($fields['location_address'])) { $data['location'] = [ '@type' => 'Place', ]; if (!empty($fields['location_name'])) { $data['location']['name'] = $fields['location_name']; } if (!empty($fields['location_address'])) { $data['location']['address'] = [ '@type' => 'PostalAddress', 'streetAddress' => $fields['location_address'], ]; } } if (!empty($fields['organizer_name'])) { $data['organizer'] = [ '@type' => 'Organization', 'name' => $fields['organizer_name'], ]; if (!empty($fields['organizer_url'])) { $data['organizer']['url'] = $fields['organizer_url']; } } if (!empty($fields['offer_price'])) { $data['offers'] = [ '@type' => 'Offer', 'price' => $fields['offer_price'], 'priceCurrency' => !empty($fields['offer_priceCurrency']) ? $fields['offer_priceCurrency'] : 'USD', 'availability' => 'https://schema.org/' . (!empty($fields['offer_availability']) ? $fields['offer_availability'] : 'InStock'), ]; if (!empty($fields['offer_url'])) { $data['offers']['url'] = $fields['offer_url']; } } return $data; } // ====================================================================== // New Schema Type: JobPosting // ====================================================================== /** * Render JobPosting schema fields */ private function render_jobposting_fields($fields, $index = 0) { $title = isset($fields['title']) ? $fields['title'] : ''; $description = isset($fields['description']) ? $fields['description'] : ''; $datePosted = isset($fields['datePosted']) ? $fields['datePosted'] : ''; $validThrough = isset($fields['validThrough']) ? $fields['validThrough'] : ''; $employmentType = isset($fields['employmentType']) ? $fields['employmentType'] : 'FULL_TIME'; $hiringOrganization_name = isset($fields['hiringOrganization_name']) ? $fields['hiringOrganization_name'] : ''; $hiringOrganization_sameAs = isset($fields['hiringOrganization_sameAs']) ? $fields['hiringOrganization_sameAs'] : ''; $hiringOrganization_logo = isset($fields['hiringOrganization_logo']) ? $fields['hiringOrganization_logo'] : ''; $jobLocation_address = isset($fields['jobLocation_address']) ? $fields['jobLocation_address'] : ''; $baseSalary_currency = isset($fields['baseSalary_currency']) ? $fields['baseSalary_currency'] : ''; $baseSalary_value = isset($fields['baseSalary_value']) ? $fields['baseSalary_value'] : ''; $baseSalary_unitText = isset($fields['baseSalary_unitText']) ? $fields['baseSalary_unitText'] : 'YEAR'; ?>

Job Posting Information

Fields marked with * are required.

'title', 'message' => 'Job Posting schema requires a Title.']; } if (empty($fields['description']) || trim($fields['description']) === '') { $errors[] = ['field' => 'description', 'message' => 'Job Posting schema requires a Description.']; } if (empty($fields['datePosted']) || trim($fields['datePosted']) === '') { $errors[] = ['field' => 'datePosted', 'message' => 'Job Posting schema requires a Date Posted.']; } if (empty($fields['hiringOrganization_name']) || trim($fields['hiringOrganization_name']) === '') { $errors[] = ['field' => 'hiringOrganization_name', 'message' => 'Job Posting schema requires a Hiring Organization.']; } if (empty($fields['jobLocation_address']) || trim($fields['jobLocation_address']) === '') { $errors[] = ['field' => 'jobLocation_address', 'message' => 'Job Posting schema requires a Job Location.']; } return $errors; } /** * Generate JobPosting JSON-LD */ private function generate_jobposting_json_ld($fields, $post) { $data = []; if (!empty($fields['title'])) { $data['title'] = $fields['title']; } if (!empty($fields['description'])) { $data['description'] = $fields['description']; } if (!empty($fields['datePosted'])) { $data['datePosted'] = $fields['datePosted']; } if (!empty($fields['validThrough'])) { $data['validThrough'] = $fields['validThrough']; } if (!empty($fields['employmentType'])) { $data['employmentType'] = $fields['employmentType']; } if (!empty($fields['hiringOrganization_name'])) { $data['hiringOrganization'] = [ '@type' => 'Organization', 'name' => $fields['hiringOrganization_name'], ]; if (!empty($fields['hiringOrganization_sameAs'])) { $data['hiringOrganization']['sameAs'] = $fields['hiringOrganization_sameAs']; } if (!empty($fields['hiringOrganization_logo'])) { $data['hiringOrganization']['logo'] = $fields['hiringOrganization_logo']; } } if (!empty($fields['jobLocation_address'])) { $data['jobLocation'] = [ '@type' => 'Place', 'address' => [ '@type' => 'PostalAddress', 'streetAddress' => $fields['jobLocation_address'], ], ]; } if (!empty($fields['baseSalary_value']) && !empty($fields['baseSalary_currency'])) { $data['baseSalary'] = [ '@type' => 'MonetaryAmount', 'currency' => $fields['baseSalary_currency'], 'value' => [ '@type' => 'QuantitativeValue', 'value' => $fields['baseSalary_value'], 'unitText' => !empty($fields['baseSalary_unitText']) ? $fields['baseSalary_unitText'] : 'YEAR', ], ]; } return $data; } // ====================================================================== // New Schema Type: Review // ====================================================================== /** * Render Review schema fields */ private function render_review_fields($fields, $index = 0) { $itemReviewed_name = isset($fields['itemReviewed_name']) ? $fields['itemReviewed_name'] : ''; $reviewRating_ratingValue = isset($fields['reviewRating_ratingValue']) ? $fields['reviewRating_ratingValue'] : ''; $reviewRating_bestRating = isset($fields['reviewRating_bestRating']) ? $fields['reviewRating_bestRating'] : '5'; $author_name = isset($fields['author_name']) ? $fields['author_name'] : ''; $reviewBody = isset($fields['reviewBody']) ? $fields['reviewBody'] : ''; ?>

Review Information

Fields marked with * are required.

'itemReviewed_name', 'message' => 'Review schema requires an Item Reviewed name.']; } if (empty($fields['reviewRating_ratingValue']) || floatval($fields['reviewRating_ratingValue']) <= 0) { $errors[] = ['field' => 'reviewRating_ratingValue', 'message' => 'Review schema requires a Rating Value.']; } if (empty($fields['author_name']) || trim($fields['author_name']) === '') { $errors[] = ['field' => 'author_name', 'message' => 'Review schema requires an Author Name.']; } return $errors; } /** * Generate Review JSON-LD */ private function generate_review_json_ld($fields, $post) { $data = []; if (!empty($fields['itemReviewed_name'])) { $data['itemReviewed'] = [ '@type' => 'Thing', 'name' => $fields['itemReviewed_name'], ]; } if (!empty($fields['reviewRating_ratingValue'])) { $data['reviewRating'] = [ '@type' => 'Rating', 'ratingValue' => $fields['reviewRating_ratingValue'], 'bestRating' => !empty($fields['reviewRating_bestRating']) ? $fields['reviewRating_bestRating'] : '5', ]; } if (!empty($fields['author_name'])) { $data['author'] = [ '@type' => 'Person', 'name' => $fields['author_name'], ]; } if (!empty($fields['reviewBody'])) { $data['reviewBody'] = $fields['reviewBody']; } return $data; } // ====================================================================== // New Schema Type: Course // ====================================================================== /** * Render Course schema fields */ private function render_course_fields($fields, $index = 0) { $name = isset($fields['name']) ? $fields['name'] : ''; $description = isset($fields['description']) ? $fields['description'] : ''; $provider_name = isset($fields['provider_name']) ? $fields['provider_name'] : ''; $provider_sameAs = isset($fields['provider_sameAs']) ? $fields['provider_sameAs'] : ''; $courseInstance_courseMode = isset($fields['courseInstance_courseMode']) ? $fields['courseInstance_courseMode'] : 'online'; $courseInstance_instructor_name = isset($fields['courseInstance_instructor_name']) ? $fields['courseInstance_instructor_name'] : ''; ?>

Course Information

Fields marked with * are required.

'name', 'message' => 'Course schema requires a Name.']; } if (empty($fields['provider_name']) || trim($fields['provider_name']) === '') { $errors[] = ['field' => 'provider_name', 'message' => 'Course schema requires a Provider Name.']; } return $errors; } /** * Generate Course JSON-LD */ private function generate_course_json_ld($fields, $post) { $data = []; if (!empty($fields['name'])) { $data['name'] = $fields['name']; } if (!empty($fields['description'])) { $data['description'] = $fields['description']; } if (!empty($fields['provider_name'])) { $data['provider'] = [ '@type' => 'Organization', 'name' => $fields['provider_name'], ]; if (!empty($fields['provider_sameAs'])) { $data['provider']['sameAs'] = $fields['provider_sameAs']; } } if (!empty($fields['courseInstance_courseMode']) || !empty($fields['courseInstance_instructor_name'])) { $data['hasCourseInstance'] = [ '@type' => 'CourseInstance', ]; if (!empty($fields['courseInstance_courseMode'])) { $data['hasCourseInstance']['courseMode'] = $fields['courseInstance_courseMode']; } if (!empty($fields['courseInstance_instructor_name'])) { $data['hasCourseInstance']['instructor'] = [ '@type' => 'Person', 'name' => $fields['courseInstance_instructor_name'], ]; } } return $data; } // ====================================================================== // New Schema Type: Organization // ====================================================================== /** * Render Organization schema fields */ private function render_organization_fields($fields, $index = 0) { $name = isset($fields['name']) ? $fields['name'] : ''; $url = isset($fields['url']) ? $fields['url'] : ''; $logo = isset($fields['logo']) ? $fields['logo'] : ''; $contactPoint_telephone = isset($fields['contactPoint_telephone']) ? $fields['contactPoint_telephone'] : ''; $contactPoint_contactType = isset($fields['contactPoint_contactType']) ? $fields['contactPoint_contactType'] : ''; $sameAs = isset($fields['sameAs']) ? $fields['sameAs'] : ''; ?>

Organization Information

'ImageObject', 'url' => $fields['logo'], ]; } if (!empty($fields['contactPoint_telephone']) || !empty($fields['contactPoint_contactType'])) { $data['contactPoint'] = [ '@type' => 'ContactPoint', ]; if (!empty($fields['contactPoint_telephone'])) { $data['contactPoint']['telephone'] = $fields['contactPoint_telephone']; } if (!empty($fields['contactPoint_contactType'])) { $data['contactPoint']['contactType'] = $fields['contactPoint_contactType']; } } if (!empty($fields['sameAs'])) { $urls = array_filter(array_map('trim', explode("\n", $fields['sameAs']))); if (!empty($urls)) { $data['sameAs'] = $urls; } } return $data; } // ====================================================================== // New Schema Type: Person // ====================================================================== /** * Render Person schema fields */ private function render_person_fields($fields, $index = 0) { // Auto-populate from post author if fields are empty $author_id = 0; global $post; if ($post && isset($post->post_author)) { $author_id = $post->post_author; } $name = isset($fields['name']) ? $fields['name'] : ''; $url = isset($fields['url']) ? $fields['url'] : ''; $jobTitle = isset($fields['jobTitle']) ? $fields['jobTitle'] : ''; $worksFor = isset($fields['worksFor']) ? $fields['worksFor'] : ''; $email = isset($fields['email']) ? $fields['email'] : ''; $image = isset($fields['image']) ? $fields['image'] : ''; $sameAs = isset($fields['sameAs']) ? $fields['sameAs'] : ''; // Build auto-populate hints from author profile $author_name = $author_id ? get_the_author_meta('display_name', $author_id) : ''; $author_url = $author_id ? get_author_posts_url($author_id) : ''; $author_email = $author_id ? get_the_author_meta('user_email', $author_id) : ''; $author_avatar = $author_id ? get_avatar_url($author_id, ['size' => 256]) : ''; $author_desc = $author_id ? get_the_author_meta('description', $author_id) : ''; ?>

Person Information

Leave fields blank to auto-populate from author profile () when generating schema output.

post_author) ? $post->post_author : 0; $name = !empty($fields['name']) ? $fields['name'] : ($author_id ? get_the_author_meta('display_name', $author_id) : ''); $url = !empty($fields['url']) ? $fields['url'] : ($author_id ? get_author_posts_url($author_id) : ''); $image = !empty($fields['image']) ? $fields['image'] : ($author_id ? get_avatar_url($author_id, ['size' => 256]) : ''); if (!empty($name)) { $data['name'] = $name; } if (!empty($url)) { $data['url'] = $url; } if (!empty($fields['jobTitle'])) { $data['jobTitle'] = $fields['jobTitle']; } if (!empty($fields['worksFor'])) { $data['worksFor'] = [ '@type' => 'Organization', 'name' => $fields['worksFor'], ]; } if (!empty($fields['email'])) { $data['email'] = $fields['email']; } if (!empty($image)) { $data['image'] = $image; } if (!empty($fields['sameAs'])) { $urls = array_filter(array_map('trim', explode("\n", $fields['sameAs']))); if (!empty($urls)) { $data['sameAs'] = $urls; } } return $data; } // ====================================================================== // New Schema Type: WebSite // ====================================================================== /** * Render WebSite schema fields */ private function render_website_fields($fields, $index = 0) { $name = isset($fields['name']) ? $fields['name'] : ''; $url = isset($fields['url']) ? $fields['url'] : ''; $searchbox_enabled = isset($fields['searchbox_enabled']) ? (bool) $fields['searchbox_enabled'] : false; $searchbox_query_input = isset($fields['searchbox_query_input']) ? $fields['searchbox_query_input'] : ''; ?>

Website Information

Adds SearchAction for Google Sitelinks Searchbox in SERPs.

Search URL template (default: /?s={search_term_string})

'SearchAction', 'target' => [ '@type' => 'EntryPoint', 'urlTemplate' => $base_url . $search_target, ], 'query-input' => 'required name=search_term_string', ]; } return $data; } // ====================================================================== // New Schema Type: NewsArticle // ====================================================================== /** * Render NewsArticle schema fields */ private function render_newsarticle_fields($fields, $index = 0) { $organization_name = isset($fields['organization_name']) ? $fields['organization_name'] : ''; $organization_logo = isset($fields['organization_logo']) ? $fields['organization_logo'] : ''; ?>
render_override_fields_section($fields, $index, 'NewsArticle', true); ?>

News Article Information

Optional fields to enhance your news article schema.

The name of the organization that published this article.

Logo of the organization (recommended for better rich results).

get_effective_schema_value('title_override', $fields, $post_id); $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id); $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id); if (empty($effective_title) || trim($effective_title) === '') { $errors[] = [ 'field' => 'headline', 'message' => 'News Article schema requires a Headline. Please add a post title or override it in the Override Default Fields section.' ]; } if (empty($effective_description) || trim($effective_description) === '') { $errors[] = [ 'field' => 'description', 'message' => 'News Article schema requires a Description. Please add post content, an excerpt, or override it in the Override Default Fields section.' ]; } if (empty($effective_image)) { $errors[] = [ 'field' => 'image', 'message' => 'News Article schema requires an Image. Please set a featured image or override it in the Override Default Fields section.' ]; } return $errors; } /** * Generate NewsArticle JSON-LD (same shape as Article) */ private function generate_newsarticle_json_ld($fields, $post) { return $this->generate_article_json_ld($fields, $post); } // ====================================================================== // Cross-plugin schema deduplication // ====================================================================== /** * Check if a conflicting SEO plugin already outputs this schema type. * * Per-type suppression: only suppress the specific @type that a competing * plugin is known to output, not all MetaSync schema. * * The type key is matched case-insensitively. Stored per-post type keys use * mixed casing — the metabox dropdown and the MCP tool enum both offer * 'article', 'product' and 'recipe' in lowercase while the remaining keys * ('LocalBusiness', 'NewsArticle', 'Organization', …) keep their Schema.org * casing — and the hardcoded callers in output_schema_markup() pass the * Schema.org spelling. Comparing the raw value against a single casing can * therefore only ever match part of the key set, so both sides are folded to * lowercase here. This mirrors the strtolower() matching the external * importer already uses on incoming schema types. * * @param string $type Schema.org type name or stored schema type key * @return bool True if MetaSync should output this type; false to suppress */ private function should_output_schema_type($type) { $type_key = strtolower((string) $type); // Yoast SEO — outputs these types via its schema graph if (class_exists('WPSEO_Schema_Context')) { $yoast_types = ['article', 'newsarticle', 'website', 'organization', 'person', 'webpage', 'breadcrumblist']; if (in_array($type_key, $yoast_types, true)) { return false; } } // Yoast Local SEO — specifically for LocalBusiness if ($type_key === 'localbusiness' && class_exists('WPSEO_Local_Core')) { return false; } // Rank Math — outputs these types via its schema module if (class_exists('RankMath\\Schema\\JsonLD')) { $rankmath_types = ['article', 'newsarticle', 'website', 'organization', 'person', 'webpage', 'breadcrumblist']; if (in_array($type_key, $rankmath_types, true)) { return false; } } // AIOSEO — outputs these types when its schema feature is active if (has_filter('aioseo_schema')) { $aioseo_types = ['article', 'newsarticle', 'website', 'organization', 'person', 'webpage', 'breadcrumblist']; if (in_array($type_key, $aioseo_types, true)) { return false; } } return true; } // ====================================================================== // WooCommerce integration for Product schema // ====================================================================== /** * Generate LocalBusiness JSON-LD */ private function generate_local_business_json_ld($fields) { // Deduplication: suppress if Yoast Local SEO is active // (also checked upstream in dedup_check_for_type, but kept as safety net) if (class_exists('WPSEO_Local_Core')) { return false; } $data = []; if (!empty($fields['business_name'])) { $data['name'] = $fields['business_name']; } if (!empty($fields['description'])) { $data['description'] = $fields['description']; } if (!empty($fields['url'])) { $data['url'] = $fields['url']; } if (!empty($fields['telephone'])) { $data['telephone'] = $fields['telephone']; } if (!empty($fields['price_range'])) { $data['priceRange'] = $fields['price_range']; } if (!empty($fields['image'])) { $data['image'] = $fields['image']; } // Build PostalAddress sub-object $address = []; if (!empty($fields['street_address'])) { $address['streetAddress'] = $fields['street_address']; } if (!empty($fields['city'])) { $address['addressLocality'] = $fields['city']; } if (!empty($fields['state'])) { $address['addressRegion'] = $fields['state']; } if (!empty($fields['postal_code'])) { $address['postalCode'] = $fields['postal_code']; } if (!empty($fields['country'])) { $address['addressCountry'] = $fields['country']; } if (!empty($address)) { $address['@type'] = 'PostalAddress'; $data['address'] = $address; } // Build GeoCoordinates sub-object if (!empty($fields['latitude']) && !empty($fields['longitude'])) { $data['geo'] = [ '@type' => 'GeoCoordinates', 'latitude' => $fields['latitude'], 'longitude' => $fields['longitude'], ]; } // Build openingHoursSpecification if (!empty($fields['opening_hours']) && is_array($fields['opening_hours'])) { $specs = []; foreach ($fields['opening_hours'] as $hours) { if (!empty($hours['day'])) { $spec = [ '@type' => 'OpeningHoursSpecification', 'dayOfWeek' => $hours['day'], ]; if (!empty($hours['open'])) { $spec['opens'] = $hours['open']; } if (!empty($hours['close'])) { $spec['closes'] = $hours['close']; } $specs[] = $spec; } } if (!empty($specs)) { $data['openingHoursSpecification'] = $specs; } } return $data; } /** * Generate HowTo JSON-LD */ private function generate_howto_json_ld($fields) { $data = []; // Steps if (!empty($fields['steps']) && is_array($fields['steps'])) { $data['step'] = []; foreach ($fields['steps'] as $step) { if (!empty($step['instructions'])) { $step_data = [ '@type' => 'HowToStep', 'text' => $step['instructions'], ]; if (!empty($step['image'])) { $step_data['image'] = $step['image']; } $data['step'][] = $step_data; } } } // Supplies if (!empty($fields['supplies']) && is_array($fields['supplies'])) { $data['supply'] = []; foreach ($fields['supplies'] as $supply) { if (!empty($supply)) { $data['supply'][] = [ '@type' => 'HowToSupply', 'name' => $supply, ]; } } } // Tools if (!empty($fields['tools']) && is_array($fields['tools'])) { $data['tool'] = []; foreach ($fields['tools'] as $tool) { if (!empty($tool)) { $data['tool'][] = [ '@type' => 'HowToTool', 'name' => $tool, ]; } } } // Total time (ISO 8601 duration) if (!empty($fields['total_time'])) { $data['totalTime'] = 'PT' . $fields['total_time'] . 'M'; } // Estimated cost if (!empty($fields['estimated_cost'])) { $data['estimatedCost'] = [ '@type' => 'MonetaryAmount', 'value' => $fields['estimated_cost'], ]; } return $data; } /** * Generate VideoObject JSON-LD */ private function generate_video_object_json_ld($fields) { $data = []; if (!empty($fields['video_name'])) { $data['name'] = $fields['video_name']; } if (!empty($fields['video_description'])) { $data['description'] = $fields['video_description']; } if (!empty($fields['thumbnail_url'])) { $data['thumbnailUrl'] = $fields['thumbnail_url']; } if (!empty($fields['upload_date'])) { $data['uploadDate'] = $fields['upload_date']; } if (!empty($fields['content_url'])) { $data['contentUrl'] = $fields['content_url']; } if (!empty($fields['embed_url'])) { $data['embedUrl'] = $fields['embed_url']; } if (!empty($fields['duration'])) { $data['duration'] = 'PT' . $fields['duration'] . 'M'; } return $data; } /** * Enqueue admin scripts */ public function enqueue_admin_scripts($hook) { // Only load on post/page edit screens if ($hook !== 'post.php' && $hook !== 'post-new.php') { return; } wp_enqueue_script('jquery'); // Enqueue WordPress media scripts for image uploader wp_enqueue_media(); // Enqueue schema markup admin CSS wp_enqueue_style( 'metasync-schema-markup-admin', plugin_dir_url(__FILE__) . 'css/schema-markup-admin.css', [], $this->version, 'all' ); // Enqueue schema markup admin JS wp_enqueue_script( 'metasync-schema-markup-admin', plugin_dir_url(__FILE__) . 'js/schema-markup-admin.js', ['jquery', 'wp-util'], $this->version, true ); // Localize script with nonces wp_localize_script( 'metasync-schema-markup-admin', 'metasyncSchemaMarkup', [ 'ajaxurl' => admin_url('admin-ajax.php'), 'schema_fields_nonce' => wp_create_nonce('metasync_schema_fields_nonce'), 'preview_schema_nonce' => wp_create_nonce('metasync_preview_schema_nonce') ] ); } /** * Post types the schema markup UI is registered for * * Single source of truth shared by the meta box registration and the AJAX * handlers so the two cannot drift apart. * * @return array List of supported post type slugs */ private function get_schema_supported_post_types() { return ['post', 'page']; } /** * Resolve and authorize the post targeted by a schema markup AJAX request * * Terminates the request with a JSON error when the current user may not * edit the requested post, when the post does not exist, or when the post * type is not one the schema UI applies to. A missing post and a forbidden * post return the same 403 response so the endpoint cannot be used to * enumerate which post IDs exist. * * @param int $post_id Requested post ID * @return WP_Post The authorized post object */ private function authorize_schema_post_request($post_id) { $post = get_post($post_id); if (!$post || !current_user_can('edit_post', $post_id)) { wp_send_json_error(['message' => 'Permission denied'], 403); } elseif (!in_array($post->post_type, $this->get_schema_supported_post_types(), true)) { wp_send_json_error(['message' => 'Unsupported post type'], 400); } return $post; } /** * AJAX handler for getting schema fields */ public function ajax_get_schema_fields() { // Verify nonce if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_schema_fields_nonce')) { wp_send_json_error(['message' => 'Security check failed'], 403); } $schema_type = sanitize_text_field($_POST['schema_type']); $index = isset($_POST['index']) ? intval($_POST['index']) : 0; $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; // Set global post for override fields rendering if ($post_id) { global $post; $post = $this->authorize_schema_post_request($post_id); } elseif (!current_user_can('edit_posts')) { // No post context: the markup rendered below carries no post data, // but the editor UI is still limited to users who may author posts. wp_send_json_error(['message' => 'Permission denied'], 403); } ob_start(); $this->render_schema_fields($schema_type, [], $index); $html = ob_get_clean(); wp_send_json_success($html); } /** * AJAX handler for previewing schema markup */ public function ajax_preview_schema() { // Verify nonce if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_preview_schema_nonce')) { wp_send_json_error(['message' => 'Security check failed'], 403); } // Get post ID $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; if (!$post_id) { wp_send_json_error(['message' => 'Invalid post ID']); } // Get post object, enforcing that the current user may edit it $post = $this->authorize_schema_post_request($post_id); // Check if schema is enabled $schema_enabled = isset($_POST['schema_enabled']) && $_POST['schema_enabled']; if (!$schema_enabled) { wp_send_json_error(['message' => 'Schema markup is not enabled']); } // Get schema types data $schema_types = isset($_POST['schema_types']) ? $_POST['schema_types'] : []; if (empty($schema_types)) { wp_send_json_error(['message' => 'No schema types provided']); } // Build schema data array $schema_data = [ 'enabled' => true, 'types' => [] ]; foreach ($schema_types as $type_data) { if (!empty($type_data['type'])) { $schema_type = sanitize_text_field($type_data['type']); $fields = isset($type_data['fields']) ? $type_data['fields'] : []; $sanitized_fields = $this->sanitize_schema_fields($fields, $schema_type); $schema_data['types'][] = [ 'type' => $schema_type, 'fields' => $sanitized_fields ]; } } // Validate schema requirements before generating preview $all_validation_errors = []; foreach ($schema_data['types'] as $schema_type_data) { $validation_errors = $this->validate_schema_requirements($post_id, $schema_type_data['type'], $schema_type_data['fields']); if (!empty($validation_errors)) { // Group errors by schema type with display-friendly names $schema_type_display_names = [ 'article' => 'Article', 'FAQPage' => 'FAQ', 'product' => 'Product', 'recipe' => 'Recipe', 'Event' => 'Event', 'JobPosting' => 'Job Posting', 'Review' => 'Review', 'Course' => 'Course', 'Organization' => 'Organization', 'Person' => 'Person', 'WebSite' => 'Website', 'NewsArticle' => 'News Article', 'LocalBusiness' => 'Local Business', 'HowTo' => 'How-To', 'VideoObject' => 'Video Object' ]; $schema_type_name = isset($schema_type_display_names[$schema_type_data['type']]) ? $schema_type_display_names[$schema_type_data['type']] : ucfirst($schema_type_data['type']); foreach ($validation_errors as $error) { $all_validation_errors[] = [ 'schema_type' => $schema_type_name, 'field' => $error['field'], 'message' => $error['message'] ]; } } } // If there are validation errors, return them instead of generating preview if (!empty($all_validation_errors)) { wp_send_json_error([ 'message' => 'Validation errors detected', 'validation_errors' => $all_validation_errors ]); } // Generate JSON-LD for all schema types $all_json_ld = []; foreach ($schema_data['types'] as $schema_type_data) { $json_ld = $this->generate_json_ld($post, $schema_type_data); if ($json_ld) { $all_json_ld[] = $json_ld; } } if (empty($all_json_ld)) { wp_send_json_error(['message' => 'Could not generate schema markup']); } // Build the final JSON-LD structure with @context and @graph $final_json_ld = [ '@context' => 'https://schema.org', '@graph' => $all_json_ld ]; $formatted_json = metasync_safe_json_ld_encode($final_json_ld, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); if (!is_string($formatted_json) || $formatted_json === '') { wp_send_json_error(['message' => 'Could not encode schema markup']); } // Wrap in script tags for preview $formatted_json_with_tags = ''; wp_send_json_success([ 'json' => $formatted_json_with_tags, 'types' => array_column($schema_data['types'], 'type') ]); } /** * Get the plugin name * * @return string The plugin name */ public function get_plugin_name() { return $this->plugin_name; } /** * Get the plugin version * * @return string The plugin version */ public function get_version() { return $this->version; } /** * Register REST API routes for schema content */ public function register_schema_content_rest_routes() { register_rest_route('metasync/v1', '/schema-content/(?P\d+)', [ [ 'methods' => 'GET', 'callback' => [$this, 'handle_rest_get_schema_content'], 'permission_callback' => function () { return current_user_can('edit_posts'); }, 'args' => [ 'post_id' => [ 'required' => true, 'validate_callback' => function ($param) { return is_numeric($param) && intval($param) > 0; }, 'sanitize_callback' => 'absint', ], ], ], [ 'methods' => 'POST', 'callback' => [$this, 'handle_rest_set_schema_content'], 'permission_callback' => function () { return current_user_can('edit_posts'); }, 'args' => [ 'post_id' => [ 'required' => true, 'validate_callback' => function ($param) { return is_numeric($param) && intval($param) > 0; }, 'sanitize_callback' => 'absint', ], ], ], ]); } /** * Handle GET request for schema content * * @param WP_REST_Request $request * @return WP_REST_Response */ public function handle_rest_get_schema_content(WP_REST_Request $request) { $post_id = $request->get_param('post_id'); $post = get_post($post_id); if (!$post) { return new WP_REST_Response(['error' => 'Post not found'], 404); } if (!current_user_can('edit_post', $post_id)) { return new WP_REST_Response(['error' => 'Permission denied'], 403); } $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true); $result = [ 'post_id' => $post_id, 'schema_enabled' => false, 'types' => [], ]; if (!empty($schema_data)) { $result['schema_enabled'] = isset($schema_data['enabled']) ? (bool) $schema_data['enabled'] : false; if (!empty($schema_data['types']) && is_array($schema_data['types'])) { $result['types'] = $schema_data['types']; } } return new WP_REST_Response($result, 200); } /** * Handle POST request to set schema content for a specific type * * Expects JSON body: { "schema_type": "FAQPage", "fields": { ... } } * * @param WP_REST_Request $request * @return WP_REST_Response */ public function handle_rest_set_schema_content(WP_REST_Request $request) { $post_id = $request->get_param('post_id'); $schema_type = sanitize_text_field($request->get_param('schema_type')); $fields = $request->get_param('fields'); if (empty($schema_type)) { return new WP_REST_Response(['error' => 'schema_type is required'], 400); } if (!in_array($schema_type, self::$supported_content_types, true)) { return new WP_REST_Response(['error' => 'Unsupported schema_type: ' . $schema_type], 400); } $post = get_post($post_id); if (!$post) { return new WP_REST_Response(['error' => 'Post not found'], 404); } if (!current_user_can('edit_post', $post_id)) { return new WP_REST_Response(['error' => 'Permission denied'], 403); } // Sanitize fields before saving (same as Classic Editor path) $fields = $this->sanitize_schema_fields(is_array($fields) ? $fields : [], $schema_type); // Get existing schema data $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true); if (empty($schema_data)) { $schema_data = ['enabled' => true, 'types' => []]; } // Find and update or add the schema type $found = false; foreach ($schema_data['types'] as &$type_data) { if ($type_data['type'] === $schema_type) { $type_data['fields'] = $fields; $found = true; break; } } unset($type_data); if (!$found) { $schema_data['types'][] = [ 'type' => $schema_type, 'fields' => $fields, ]; } // Save updated schema data update_post_meta($post_id, 'metasync_schema_markup', $schema_data); // Validate $validation_warnings = $this->validate_schema_requirements($post_id, $schema_type, $fields); // Update validation errors in post meta if (empty($validation_warnings)) { delete_post_meta($post_id, '_metasync_schema_validation_errors'); } else { update_post_meta($post_id, '_metasync_schema_validation_errors', $validation_warnings); } // Cross-plugin sync $this->sync_schema_to_seo_plugins($post_id, $schema_type, $fields); return new WP_REST_Response([ 'post_id' => $post_id, 'schema_type' => $schema_type, 'message' => 'Schema content updated successfully', 'validation_warnings' => $validation_warnings, ], 200); } /** * Sync schema data to third-party SEO plugins (Yoast, Rank Math) * * @param int $post_id * @param string $type Schema type * @param array $fields Schema fields */ public function sync_schema_to_seo_plugins($post_id, $type, $fields) { // Sync to Yoast SEO if (defined('WPSEO_VERSION')) { try { $this->sync_schema_to_yoast($post_id, $type, $fields); } catch (\Exception $e) { error_log('MetaSync: Yoast schema sync failed for post ' . $post_id . ' — ' . $e->getMessage()); } } // Sync to Rank Math if (defined('RANK_MATH_VERSION')) { try { $this->sync_schema_to_rank_math($post_id, $type, $fields); } catch (\Exception $e) { error_log('MetaSync: Rank Math schema sync failed for post ' . $post_id . ' — ' . $e->getMessage()); } } } /** * Build a schema.org-compatible block array for a given type and fields. * Used by both Yoast and Rank Math sync methods to avoid duplicating * the field-mapping logic. * * @param int $post_id * @param string $type * @param array $fields * @return array|null Schema block array, or null if required fields are missing. */ private function build_schema_block($post_id, $type, $fields) { switch ($type) { case 'FAQPage': if (empty($fields['faq_items']) || !is_array($fields['faq_items'])) { return null; } $main_entity = []; foreach ($fields['faq_items'] as $item) { if (!empty($item['question'])) { $main_entity[] = [ '@type' => 'Question', 'name' => $item['question'], 'acceptedAnswer' => [ '@type' => 'Answer', 'text' => isset($item['answer']) ? $item['answer'] : '', ], ]; } } if (empty($main_entity)) { return null; } return [ '@type' => 'FAQPage', 'mainEntity' => $main_entity, ]; case 'HowTo': if (empty($fields['steps']) || !is_array($fields['steps'])) { return null; } $steps = []; foreach ($fields['steps'] as $i => $step) { if (!empty($step['instructions'])) { $step_data = [ '@type' => 'HowToStep', 'position' => $i + 1, 'text' => $step['instructions'], ]; if (!empty($step['image'])) { $step_data['image'] = $step['image']; } $steps[] = $step_data; } } if (empty($steps)) { return null; } $block = [ '@type' => 'HowTo', 'name' => get_the_title($post_id), 'step' => $steps, ]; if (!empty($fields['total_time'])) { $block['totalTime'] = 'PT' . intval($fields['total_time']) . 'M'; } return $block; case 'product': if (empty($fields['price']) || floatval($fields['price']) <= 0) { return null; } $block = [ '@type' => 'Product', 'name' => get_the_title($post_id), 'offers' => [ '@type' => 'Offer', 'price' => floatval($fields['price']), 'priceCurrency' => !empty($fields['currency']) ? $fields['currency'] : 'USD', 'availability' => !empty($fields['availability']) ? 'https://schema.org/' . $fields['availability'] : 'https://schema.org/InStock', ], ]; if (!empty($fields['sku'])) { $block['sku'] = $fields['sku']; } if (!empty($fields['brand'])) { $block['brand'] = ['@type' => 'Brand', 'name' => $fields['brand']]; } return $block; case 'recipe': if (empty($fields['ingredients']) && empty($fields['instructions'])) { return null; } $block = [ '@type' => 'Recipe', 'name' => get_the_title($post_id), ]; if (!empty($fields['ingredients']) && is_array($fields['ingredients'])) { $block['recipeIngredient'] = $fields['ingredients']; } if (!empty($fields['instructions']) && is_array($fields['instructions'])) { $inst_steps = []; foreach ($fields['instructions'] as $inst) { $text = is_array($inst) ? (isset($inst['text']) ? $inst['text'] : '') : $inst; if (!empty($text)) { $inst_steps[] = ['@type' => 'HowToStep', 'text' => $text]; } } $block['recipeInstructions'] = $inst_steps; } if (!empty($fields['yield'])) { $block['recipeYield'] = $fields['yield']; } if (!empty($fields['prep_time'])) { $block['prepTime'] = 'PT' . intval($fields['prep_time']) . 'M'; } if (!empty($fields['cook_time'])) { $block['cookTime'] = 'PT' . intval($fields['cook_time']) . 'M'; } if (!empty($fields['total_time'])) { $block['totalTime'] = 'PT' . intval($fields['total_time']) . 'M'; } if (!empty($fields['calories'])) { $block['nutrition'] = ['@type' => 'NutritionInformation', 'calories' => $fields['calories']]; } return $block; } return null; } /** * Sync schema to Yoast SEO * * @param int $post_id * @param string $type * @param array $fields */ private function sync_schema_to_yoast($post_id, $type, $fields) { $schema_block = $this->build_schema_block($post_id, $type, $fields); if ($schema_block === null) { return; } // Yoast stores its custom schema blocks as a JSON-encoded array in this meta key. // We replace or append our block within that array. $yoast_schema = get_post_meta($post_id, '_yoast_wpseo_schema', true); if (is_string($yoast_schema) && !empty($yoast_schema)) { $decoded = json_decode($yoast_schema, true); $yoast_schema = is_array($decoded) ? $decoded : []; } if (!is_array($yoast_schema)) { $yoast_schema = []; } $replaced = false; foreach ($yoast_schema as &$existing) { if (isset($existing['@type']) && $existing['@type'] === $schema_block['@type']) { $existing = $schema_block; $replaced = true; break; } } unset($existing); if (!$replaced) { $yoast_schema[] = $schema_block; } update_post_meta($post_id, '_yoast_wpseo_schema', wp_json_encode($yoast_schema)); } /** * Sync schema to Rank Math * * @param int $post_id * @param string $type * @param array $fields */ private function sync_schema_to_rank_math($post_id, $type, $fields) { $rm_data = $this->build_schema_block($post_id, $type, $fields); if ($rm_data === null) { return; } $meta_key = 'rank_math_schema_' . $rm_data['@type']; update_post_meta($post_id, $meta_key, $rm_data); } /** * Get images for schema markup * Currently returns featured image, but designed to support multiple sources in future * * @param int $post_id The post ID * @return array Array of image URLs */ private function get_post_images($post_id) { $images = []; // 1. Featured Image (Post Thumbnail) $featured_image_id = get_post_thumbnail_id($post_id); if ($featured_image_id) { $featured_image_url = wp_get_attachment_image_url($featured_image_id, 'full'); if ($featured_image_url) { $images[] = $featured_image_url; } } // Future expansion points: // 2. Gallery images from post content // 3. Images from post attachments // 4. WooCommerce product gallery (if WooCommerce is active) // 5. Custom field images // 6. Images from post content (img tags) return $images; } }