*/
// 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');
?>
'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);
?>
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));
?>
These fields are required and will be used in schema markup.
Description: *
Reset
Default: {{post_description}} ()
render_override_fields_section($fields, $index, 'article', true); ?>
Article Information
Optional fields to enhance your article schema.
'', 'answer' => '']];
?>
FAQ Questions & Answers
Add questions and answers for your FAQ page. Both fields are required for each FAQ item.
Add Question
render_override_fields_section($fields, $index, 'product', true); ?>
Product Information
Fill in the product details below. Fields marked with * are required.
SKU:
Brand:
Currency:
>USD
>EUR
>GBP
>CAD
>AUD
Availability:
>In Stock
>Out of Stock
>Pre-Order
>Limited Availability
Condition:
>New
>Used
>Refurbished
>Damaged
'', 'image' => '']];
?>
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';
?>
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 `` to `<\/` so a literal `` 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 `` escaped for safe embedding
echo "\n" . '' . "\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'] : '';
?>
'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';
?>
'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'] : '';
?>
'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'] : '';
?>
'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'] : '';
?>
'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) : '';
?>
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'] : '';
?>
'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.
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;
}
}