PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / schema-markup / class-metasync-schema-markup.php

class-metasync-schema-markup.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at schema-markup/class-metasync-schema-markup.php

4,349 lines 212.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The Schema Markup functionality of the plugin.
5 *
6 * @link https://searchatlas.com
7 * @since 1.0.0
8 * @package Metasync
9 * @subpackage Metasync/schema-markup
10 * @author Engineering Team <[email protected]>
11 */
12
13 // Abort if this file is accessed directly.
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 class Metasync_Schema_Markup
19 {
20 private $plugin_name;
21 private $version;
22 private $common;
23
24 /** Shared instance — prevents MCP tools from re-registering hooks */
25 private static $instance = null;
26
27 /** Schema types supported by the content REST/MCP endpoints */
28 private static $supported_content_types = [
29 'article', 'FAQPage', 'product', 'recipe',
30 'Event', 'JobPosting', 'Review', 'Course',
31 'Organization', 'Person', 'WebSite', 'NewsArticle',
32 'LocalBusiness', 'HowTo', 'VideoObject',
33 ];
34
35 /**
36 * Returns the shared instance, or creates one if none exists yet.
37 * MCP tools use this instead of `new Metasync_Schema_Markup()` to
38 * avoid re-registering all WordPress hooks on every tool call.
39 */
40 public static function get_instance()
41 {
42 if (null === self::$instance) {
43 self::$instance = new self('metasync', defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0');
44 }
45 return self::$instance;
46 }
47
48 public function __construct($plugin_name, $version)
49 {
50 $this->plugin_name = $plugin_name;
51 $this->version = $version;
52 $this->common = new Metasync_Common();
53
54 // Capture first instance so MCP tools can reuse it without re-registering hooks
55 if (null === self::$instance) {
56 self::$instance = $this;
57 }
58
59 // Initialize hooks
60 add_action('admin_init', [$this, 'add_schema_markup_meta_box']);
61 add_action('save_post', [$this, 'save_schema_markup_data']);
62 add_action('wp_head', [$this, 'output_schema_markup']);
63 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
64 add_action('admin_notices', [$this, 'display_schema_validation_notices']);
65 add_action('rest_api_init', [$this, 'register_schema_content_rest_routes']);
66 }
67
68 /**
69 * Add the Schema Markup meta box to post and page editors
70 */
71 public function add_schema_markup_meta_box()
72 {
73 // Don't show metabox if user's role doesn't have plugin access
74 if (!Metasync::current_user_has_plugin_access()) {
75 return;
76 }
77
78 // Don't show metabox if disabled in Post/Page Editor Settings
79 $general_settings = Metasync::get_option('general', []);
80 if (!empty($general_settings['disable_schema_markup_metabox'])) {
81 return;
82 }
83
84 // LPS / custom-HTML pages bake their own SEO (incl. any schema) into their HTML
85 // bundle, served before wp_head — so this box does nothing on them. Hide it; the
86 // SEO read-only notice covers the messaging.
87 $lps_post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- read-only post ID to pick meta boxes
88 if (function_exists('metasync_is_custom_or_lps_page') && $lps_post_id > 0 && metasync_is_custom_or_lps_page($lps_post_id)) {
89 return;
90 }
91
92 $plugin_name = Metasync::get_effective_plugin_name();
93
94 add_meta_box(
95 'metasync-schema-markup',
96 "Schema Markup by $plugin_name",
97 [$this, 'schema_markup_meta_box_display'],
98 $this->get_schema_supported_post_types(),
99 'normal',
100 'default'
101 );
102 }
103
104 /**
105 * Display the Schema Markup meta box
106 */
107 public function schema_markup_meta_box_display($post)
108 {
109 // Get existing schema data
110 $schema_data = get_post_meta($post->ID, 'metasync_schema_markup', true);
111 $schema_enabled = isset($schema_data['enabled']) ? $schema_data['enabled'] : false;
112 $schema_types = isset($schema_data['types']) ? $schema_data['types'] : [];
113
114 // Get validation errors if any
115 $validation_errors = get_post_meta($post->ID, '_metasync_schema_validation_errors', true);
116 $has_errors = !empty($validation_errors) && is_array($validation_errors);
117
118 // Schema is always enabled by default
119 $global_schema_enabled = true;
120
121 // Add nonce for security
122 wp_nonce_field('metasync_schema_markup_nonce', 'metasync_schema_markup_nonce');
123
124 ?>
125 <div class="metasync-schema-markup-container">
126
127 <?php if (!$global_schema_enabled): ?>
128 <div class="metasync-schema-global-disabled-notice" style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 15px 0; border-radius: 4px;">
129 <p style="margin: 0 0 8px 0; font-weight: 600; color: #856404;">
130 <span class="dashicons dashicons-info" style="color: #ffc107; vertical-align: middle;"></span>
131 Global Schema Markup is Disabled
132 </p>
133 <p style="margin: 0; color: #856404; font-size: 13px;">
134 Schema markup is disabled globally. Please enable it in
135 <a href="<?php echo esc_url(admin_url('admin.php?page=searchatlas')); ?>" target="_blank">General Configuration → Enable Schema</a>
136 for schema markup to be output on the frontend.
137 </p>
138 </div>
139 <?php endif; ?>
140 <p>
141 <label>
142 <input type="checkbox" name="schema_markup[enabled]" value="1" <?php checked($schema_enabled, true); ?>>
143 Enable Schema Markup for this post/page
144 </label>
145 </p>
146
147 <?php if ($schema_enabled && $has_errors): ?>
148 <div class="metasync-schema-validation-warning" style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 15px 0; border-radius: 4px;">
149 <p style="margin: 0 0 8px 0; font-weight: 600; color: #856404;">
150 <span class="dashicons dashicons-warning" style="color: #ffc107;"></span>
151 Validation Issues Detected
152 </p>
153 <ul style="margin: 0; padding-left: 20px; color: #856404;">
154 <?php foreach ($validation_errors as $error): ?>
155 <li><?php echo wp_kses_post($error['message']); ?></li>
156 <?php endforeach; ?>
157 </ul>
158 </div>
159 <?php endif; ?>
160
161 <div class="schema-types-container" style="<?php echo $schema_enabled ? '' : 'display: none;'; ?>">
162 <div class="schema-types-header" style="margin-bottom: 20px;">
163 <h4 style="margin: 0 0 10px 0;">Schema Types</h4>
164 <p class="description" style="margin: 0;">Add multiple schema types to enhance your content's search visibility.</p>
165 </div>
166
167 <div id="schema-types-list">
168 <?php if (!empty($schema_types)): ?>
169 <?php foreach ($schema_types as $index => $schema_type_data): ?>
170 <?php $this->render_schema_type_item($index, $schema_type_data); ?>
171 <?php endforeach; ?>
172 <?php else: ?>
173 <div class="no-schema-types" style="text-align: center; padding: 20px; background: #f9f9f9; border: 2px dashed #ddd; border-radius: 4px; color: #666;">
174 <p style="margin: 0;">No schema types added yet. Click "Add Schema Type" to get started.</p>
175 </div>
176 <?php endif; ?>
177 </div>
178
179 <div class="add-schema-type-section" style="margin-top: 20px;">
180 <button type="button" class="button button-primary" id="add_schema_type_button">
181 <span class="dashicons dashicons-plus"></span> Add Schema Type
182 </button>
183 </div>
184
185 <div class="schema-preview-section" style="margin-top: 20px;">
186 <button type="button" class="button button-secondary" id="preview_schema_button">
187 <span class="dashicons dashicons-visibility"></span> Preview Schema
188 </button>
189 <button type="button" class="button button-secondary" id="copy_schema_button" style="display: none;">
190 <span class="dashicons dashicons-clipboard"></span> Copy to Clipboard
191 </button>
192 </div>
193
194 <div id="schema-preview-output" style="display: none; margin-top: 15px; padding: 15px; background: #f9f9f9; border: 1px solid #ddd; border-radius: 4px;">
195 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
196 <h4 style="margin: 0;">JSON-LD Preview:</h4>
197 <button type="button" class="button-link" id="close_preview" style="color: #dc3232; text-decoration: none;">
198 <span class="dashicons dashicons-no-alt"></span> Close
199 </button>
200 </div>
201 <pre id="schema-json-preview" style="background: #1e1e1e; color: #ffffff; padding: 15px; border: 1px solid #333; border-radius: 4px; overflow-x: auto; max-height: 500px; font-family: 'Courier New', Consolas, monospace; font-size: 13px; line-height: 1.5;"></pre>
202 </div>
203 </div>
204 </div>
205 <?php
206 }
207
208 /**
209 * Render individual schema type item
210 */
211 private function render_schema_type_item($index, $schema_type_data)
212 {
213 $index = (int) $index;
214 $schema_type = isset($schema_type_data['type']) ? $schema_type_data['type'] : '';
215 $schema_fields = isset($schema_type_data['fields']) ? $schema_type_data['fields'] : [];
216 $schema_type_names = [
217 'article' => 'Article',
218 'FAQPage' => 'FAQ',
219 'product' => 'Product',
220 'recipe' => 'Recipe',
221 'Event' => 'Event',
222 'JobPosting' => 'Job Posting',
223 'Review' => 'Review',
224 'Course' => 'Course',
225 'Organization' => 'Organization',
226 'Person' => 'Person',
227 'WebSite' => 'Website',
228 'NewsArticle' => 'News Article',
229 'LocalBusiness' => 'Local Business',
230 'HowTo' => 'How-To',
231 'VideoObject' => 'Video Object'
232 ];
233 $display_name = isset($schema_type_names[$schema_type]) ? $schema_type_names[$schema_type] : ucfirst($schema_type);
234
235 ?>
236 <div class="schema-type-item" data-index="<?php echo esc_attr((string) $index); ?>" style="margin: 15px 0; padding: 15px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9;">
237 <div class="schema-type-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
238 <h5 style="margin: 0; color: #333;">
239 <span class="dashicons dashicons-tag" style="color: #0073aa;"></span>
240 <?php echo esc_html($display_name); ?> Schema
241 </h5>
242 <button type="button" class="button button-link remove-schema-type" style="color: #dc3232; text-decoration: none;">
243 <span class="dashicons dashicons-trash"></span> Remove
244 </button>
245 </div>
246
247 <div class="schema-type-content">
248 <input type="hidden" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][type]" value="<?php echo esc_attr($schema_type); ?>">
249
250 <div class="schema-fields-container">
251 <?php $this->render_schema_fields($schema_type, $schema_fields, $index); ?>
252 </div>
253 </div>
254 </div>
255 <?php
256 }
257
258 /**
259 * Render schema fields based on type
260 */
261 private function render_schema_fields($schema_type, $schema_fields, $index = 0)
262 {
263 switch ($schema_type) {
264 case 'article':
265 $this->render_article_fields($schema_fields, $index);
266 break;
267 case 'FAQPage':
268 $this->render_faq_fields($schema_fields, $index);
269 break;
270 case 'product':
271 $this->render_product_fields($schema_fields, $index);
272 break;
273 case 'recipe':
274 $this->render_recipe_fields($schema_fields, $index);
275 break;
276 case 'Event':
277 $this->render_event_fields($schema_fields, $index);
278 break;
279 case 'JobPosting':
280 $this->render_jobposting_fields($schema_fields, $index);
281 break;
282 case 'Review':
283 $this->render_review_fields($schema_fields, $index);
284 break;
285 case 'Course':
286 $this->render_course_fields($schema_fields, $index);
287 break;
288 case 'Organization':
289 $this->render_organization_fields($schema_fields, $index);
290 break;
291 case 'Person':
292 $this->render_person_fields($schema_fields, $index);
293 break;
294 case 'WebSite':
295 $this->render_website_fields($schema_fields, $index);
296 break;
297 case 'NewsArticle':
298 $this->render_newsarticle_fields($schema_fields, $index);
299 break;
300 case 'LocalBusiness':
301 $this->render_local_business_fields($schema_fields, $index);
302 break;
303 case 'HowTo':
304 $this->render_howto_fields($schema_fields, $index);
305 break;
306 case 'VideoObject':
307 $this->render_video_object_fields($schema_fields, $index);
308 break;
309 default:
310 echo '<p>Select a schema type to see available fields.</p>';
311 break;
312 }
313 }
314
315 /**
316 * Get default values for schema fields (title, description, image)
317 *
318 * @param int $post_id Post ID
319 * @return array Array with 'title', 'description', 'image' keys
320 */
321 private function get_default_schema_values($post_id)
322 {
323 $defaults = [
324 'title' => get_the_title($post_id),
325 'description' => '',
326 'image' => ''
327 ];
328
329 // Get description: excerpt first, then trimmed content
330 $post_excerpt = get_the_excerpt($post_id);
331 if (!empty($post_excerpt)) {
332 $defaults['description'] = $post_excerpt;
333 } else {
334 $post_content = get_post_field('post_content', $post_id);
335 if (!empty($post_content)) {
336 $defaults['description'] = wp_trim_words($post_content, 30);
337 }
338 }
339
340 // Get featured image
341 $featured_image_id = get_post_thumbnail_id($post_id);
342 if ($featured_image_id) {
343 $featured_image_url = wp_get_attachment_image_url($featured_image_id, 'full');
344 if ($featured_image_url) {
345 $defaults['image'] = $featured_image_url;
346 }
347 }
348
349 return $defaults;
350 }
351
352 /**
353 * Render override default fields section
354 *
355 * @param array $fields Schema fields
356 * @param int $index Schema type index
357 * @param string $schema_type Schema type (article, product, recipe)
358 * @param bool $include_image Whether to include image override field
359 */
360 private function render_override_fields_section($fields, $index, $schema_type, $include_image = true)
361 {
362 global $post;
363 $post_id = $post ? $post->ID : 0;
364
365 if (!$post_id) {
366 return;
367 }
368
369 $defaults = $this->get_default_schema_values($post_id);
370
371 // Get override values - only populate with placeholders for NEW schemas (keys don't exist)
372 // If keys exist but are empty, respect the user's choice to leave them blank
373 if (!array_key_exists('title_override', $fields)) {
374 // New schema - populate with placeholder
375 $title_override = '{{post_title}}';
376 } else {
377 // Existing schema - respect saved value (even if blank)
378 $title_override = $fields['title_override'];
379 }
380
381 if (!array_key_exists('description_override', $fields)) {
382 // New schema - populate with placeholder
383 $description_override = '{{post_description}}';
384 } else {
385 // Existing schema - respect saved value (even if blank)
386 $description_override = $fields['description_override'];
387 }
388
389 if (!array_key_exists('image_override', $fields)) {
390 // New schema - populate with placeholder
391 $image_override = '{{featured_image}}';
392 } else {
393 // Existing schema - respect saved value (even if blank)
394 $image_override = $fields['image_override'];
395 }
396
397 // Use override value for image preview if provided and not a placeholder, otherwise use default
398 $image_placeholder = ($image_override && !$this->is_placeholder($image_override)) ? $image_override : $defaults['image'];
399
400 // Check if any overrides are active (not empty and not placeholders)
401 $has_overrides = (!empty($title_override) && !$this->is_placeholder($title_override)) ||
402 (!empty($description_override) && !$this->is_placeholder($description_override)) ||
403 (!empty($image_override) && !$this->is_placeholder($image_override));
404
405 ?>
406 <div class="schema-override-fields-section" style="margin-bottom: 20px;">
407 <div class="schema-override-header" style="background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; padding: 10px; cursor: pointer;" data-toggle-target="override-fields-<?php echo esc_attr((string) $index); ?>">
408 <div style="display: flex; justify-content: space-between; align-items: center;">
409 <h4 style="margin: 0; font-size: 14px; font-weight: 600; color: #333;">
410 <span class="dashicons dashicons-edit" style="color: #0073aa; vertical-align: middle;"></span>
411 Override Default Fields
412 <?php if ($has_overrides): ?>
413 <span class="override-indicator" style="background: #0073aa; color: white; padding: 2px 8px; border-radius: 3px; font-size: 11px; margin-left: 8px;">Active</span>
414 <?php endif; ?>
415 </h4>
416 <span class="dashicons dashicons-arrow-down-alt2 toggle-icon" style="color: #666; transition: transform 0.3s;"></span>
417 </div>
418 </div>
419
420 <div class="schema-override-content" id="override-fields-<?php echo esc_attr((string) $index); ?>" style="display: none; padding: 15px; background: #fff; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px;">
421 <p class="description" style="margin: 0 0 15px 0; color: #000000; font-size: 13px;">
422 These fields are required and will be used in schema markup.
423 </p>
424
425 <div class="schema-field" style="margin-bottom: 15px;">
426 <label for="override_title_<?php echo esc_attr((string) $index); ?>" style="display: block; font-weight: 600; margin-bottom: 5px; color: #333;">
427 Title: <span style="color: #dc3232;">*</span>
428 </label>
429 <div style="display: flex; gap: 8px; align-items: center;">
430 <input type="text"
431 name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][title_override]"
432 id="override_title_<?php echo esc_attr((string) $index); ?>"
433 value="<?php echo esc_attr($title_override); ?>"
434 data-default-value="{{post_title}}"
435 style="flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 3px; font-size: 14px;">
436 <button type="button" class="button reset-override-field" data-field="override_title_<?php echo esc_attr((string) $index); ?>" style="flex-shrink: 0;">
437 Reset
438 </button>
439 </div>
440 <p class="description" style="margin: 5px 0 0 0; color: #000000; font-size: 12px;">
441 Default: {{post_title}} (<?php echo esc_html(wp_trim_words($defaults['title'], 10)); ?>)
442 </p>
443 </div>
444
445 <div class="schema-field" style="margin-bottom: 15px;">
446 <label for="override_description_<?php echo esc_attr((string) $index); ?>" style="display: block; font-weight: 600; margin-bottom: 5px; color: #333;">
447 Description: <span style="color: #dc3232;">*</span>
448 </label>
449 <div style="display: flex; gap: 8px; align-items: flex-start;">
450 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][description_override]"
451 id="override_description_<?php echo esc_attr((string) $index); ?>"
452 data-default-value="{{post_description}}"
453 rows="4"
454 style="flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 3px; font-size: 14px; resize: vertical;"><?php echo esc_textarea($description_override); ?></textarea>
455 <button type="button" class="button reset-override-field" data-field="override_description_<?php echo esc_attr((string) $index); ?>" style="flex-shrink: 0; margin-top: 0;">
456 Reset
457 </button>
458 </div>
459 <p class="description" style="margin: 5px 0 0 0; color: #000000; font-size: 12px;">
460 Default: {{post_description}} (<?php echo esc_html(wp_trim_words($defaults['description'], 20)); ?>)
461 </p>
462 </div>
463
464 <?php if ($include_image): ?>
465 <div class="schema-field" style="margin-bottom: 15px;">
466 <label for="override_image_<?php echo esc_attr((string) $index); ?>" style="display: block; font-weight: 600; margin-bottom: 5px; color: #333;">
467 Image: <span style="color: #dc3232;">*</span>
468 </label>
469 <div class="image-upload-container" style="margin-bottom: 10px;">
470 <div style="display: flex; gap: 8px; align-items: center; margin-bottom: 10px;">
471 <input type="text"
472 name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][image_override]"
473 id="override_image_<?php echo esc_attr((string) $index); ?>"
474 value="<?php echo esc_attr($image_override); ?>"
475 data-default-value="{{featured_image}}"
476 style="flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 3px; font-size: 14px;">
477 <button type="button" class="button upload-image-button" data-target="<?php echo esc_attr((string) $index); ?>" data-field-id="override_image_<?php echo esc_attr((string) $index); ?>">
478 Upload Image
479 </button>
480 <button type="button" class="button reset-override-field" data-field="override_image_<?php echo esc_attr((string) $index); ?>" style="flex-shrink: 0;">
481 Reset
482 </button>
483 </div>
484 <?php if ($image_placeholder): ?>
485 <div class="image-preview" id="override_image_preview_<?php echo esc_attr((string) $index); ?>" style="margin-top: 10px;">
486 <img src="<?php echo esc_attr($image_placeholder); ?>" alt="Image Preview" style="max-width: 200px; max-height: 150px; border: 1px solid #ddd; padding: 5px; border-radius: 3px;">
487 </div>
488 <?php else: ?>
489 <div class="image-preview" id="override_image_preview_<?php echo esc_attr((string) $index); ?>" style="display: none; margin-top: 10px;">
490 <img src="" alt="Image Preview" style="max-width: 200px; max-height: 150px; border: 1px solid #ddd; padding: 5px; border-radius: 3px;">
491 </div>
492 <?php endif; ?>
493 </div>
494 <p class="description" style="margin: 5px 0 0 0; color: #000000; font-size: 12px;">
495 Default: {{featured_image}} <?php if ($defaults['image']): ?>(<a href="<?php echo esc_url($defaults['image']); ?>" target="_blank">Current Featured Image</a>)<?php else: ?>(No featured image set)<?php endif; ?>
496 </p>
497 </div>
498 <?php endif; ?>
499 </div>
500 </div>
501 <?php
502 }
503
504 /**
505 * Render Article schema fields
506 */
507 private function render_article_fields($fields, $index = 0)
508 {
509 $organization_name = isset($fields['organization_name']) ? $fields['organization_name'] : '';
510 $organization_logo = isset($fields['organization_logo']) ? $fields['organization_logo'] : '';
511
512 ?>
513 <div class="schema-field-group">
514 <?php $this->render_override_fields_section($fields, $index, 'article', true); ?>
515
516 <h4>Article Information</h4>
517 <p class="description" style="margin-top: 0;">Optional fields to enhance your article schema.</p>
518
519 <div class="schema-field">
520 <label for="article_organization_name_<?php echo esc_attr((string) $index); ?>">Organization Name:</label>
521 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][organization_name]" id="article_organization_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($organization_name); ?>" placeholder="e.g., Search Atlas">
522 <p class="description">The name of the organization that published this article.</p>
523 </div>
524
525 <div class="schema-field">
526 <label for="article_organization_logo_<?php echo esc_attr((string) $index); ?>">Organization Logo:</label>
527 <div class="logo-upload-container">
528 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][organization_logo]" id="article_organization_logo_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($organization_logo); ?>" placeholder="https://example.com/logo.png" style="width: 70%; margin-right: 10px;">
529 <button type="button" class="button upload-logo-button" data-target="<?php echo esc_attr((string) $index); ?>">Upload Logo</button>
530 <button type="button" class="button remove-logo-button" data-target="<?php echo esc_attr((string) $index); ?>" style="<?php echo empty($organization_logo) ? 'display: none;' : ''; ?>">Remove</button>
531 </div>
532 <div class="logo-preview" id="logo_preview_<?php echo esc_attr((string) $index); ?>" style="<?php echo empty($organization_logo) ? 'display: none;' : ''; ?>">
533 <img src="<?php echo esc_attr($organization_logo); ?>" alt="Logo Preview" style="max-width: 200px; max-height: 100px; margin-top: 10px; border: 1px solid #ddd; padding: 5px;">
534 </div>
535 <p class="description">Logo of the organization (recommended for better rich results).</p>
536 </div>
537 </div>
538 <?php
539 }
540
541 /**
542 * Render FAQ schema fields
543 */
544 private function render_faq_fields($fields, $index = 0)
545 {
546 $index = (int) $index;
547 $faq_items = isset($fields['faq_items']) ? $fields['faq_items'] : [['question' => '', 'answer' => '']];
548
549 ?>
550 <div class="schema-field-group">
551 <h4>FAQ Questions & Answers</h4>
552 <p class="description">Add questions and answers for your FAQ page. Both fields are required for each FAQ item.</p>
553
554 <div class="faq-items-list">
555 <?php foreach ($faq_items as $faq_index => $item): ?>
556 <div class="faq-item" style="margin: 15px 0; padding: 15px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9;">
557 <div class="schema-field">
558 <label>Question <?php echo esc_html((string) ($faq_index + 1)); ?>: <span style="color: #dc3232;">*</span></label>
559 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][faq_items][<?php echo esc_attr((string) $faq_index); ?>][question]" value="<?php echo esc_attr($item['question']); ?>" placeholder="Enter your question" style="width: 100%; margin-bottom: 10px;">
560 </div>
561 <div class="schema-field">
562 <label>Answer <?php echo esc_html((string) ($faq_index + 1)); ?>: <span style="color: #dc3232;">*</span></label>
563 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][faq_items][<?php echo esc_attr((string) $faq_index); ?>][answer]" placeholder="Enter your answer" style="width: 100%; height: 80px; margin-bottom: 10px;"><?php echo esc_textarea($item['answer']); ?></textarea>
564 </div>
565 <button type="button" class="button remove-faq-item" style="background: #dc3232; color: white; border-color: #dc3232;">Remove Question</button>
566 </div>
567 <?php endforeach; ?>
568 </div>
569
570 <button type="button" class="button add-faq-item" style="margin-top: 10px;">Add Question</button>
571 </div>
572 <?php
573 }
574
575 /**
576 * Render Product schema fields
577 */
578 private function render_product_fields($fields, $index = 0)
579 {
580 $sku = isset($fields['sku']) ? $fields['sku'] : '';
581 $brand = isset($fields['brand']) ? $fields['brand'] : '';
582 $price = isset($fields['price']) ? $fields['price'] : '';
583 $currency = isset($fields['currency']) ? $fields['currency'] : 'USD';
584 $availability = isset($fields['availability']) ? $fields['availability'] : 'InStock';
585 $condition = isset($fields['condition']) ? $fields['condition'] : 'NewCondition';
586
587 ?>
588 <div class="schema-field-group">
589 <?php $this->render_override_fields_section($fields, $index, 'product', true); ?>
590
591 <h4>Product Information</h4>
592 <p class="description" style="margin-top: 0;">Fill in the product details below. Fields marked with * are required.</p>
593
594 <div class="schema-field">
595 <label for="product_sku_<?php echo esc_attr((string) $index); ?>">SKU:</label>
596 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][sku]" id="product_sku_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($sku); ?>" placeholder="Product SKU">
597 </div>
598
599 <div class="schema-field">
600 <label for="product_brand_<?php echo esc_attr((string) $index); ?>">Brand:</label>
601 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][brand]" id="product_brand_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($brand); ?>" placeholder="Product Brand">
602 </div>
603
604 <div class="schema-field">
605 <label for="product_price_<?php echo esc_attr((string) $index); ?>">Price: <span style="color: #dc3232;">*</span></label>
606 <input type="number" step="0.01" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][price]" id="product_price_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($price); ?>" placeholder="0.00">
607 <p class="description">Required: Enter the product price (must be greater than 0).</p>
608 </div>
609
610 <div class="schema-field">
611 <label for="product_currency_<?php echo esc_attr((string) $index); ?>">Currency:</label>
612 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][currency]" id="product_currency_<?php echo esc_attr((string) $index); ?>">
613 <option value="USD" <?php selected($currency, 'USD'); ?>>USD</option>
614 <option value="EUR" <?php selected($currency, 'EUR'); ?>>EUR</option>
615 <option value="GBP" <?php selected($currency, 'GBP'); ?>>GBP</option>
616 <option value="CAD" <?php selected($currency, 'CAD'); ?>>CAD</option>
617 <option value="AUD" <?php selected($currency, 'AUD'); ?>>AUD</option>
618 </select>
619 </div>
620
621 <div class="schema-field">
622 <label for="product_availability_<?php echo esc_attr((string) $index); ?>">Availability:</label>
623 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][availability]" id="product_availability_<?php echo esc_attr((string) $index); ?>">
624 <option value="InStock" <?php selected($availability, 'InStock'); ?>>In Stock</option>
625 <option value="OutOfStock" <?php selected($availability, 'OutOfStock'); ?>>Out of Stock</option>
626 <option value="PreOrder" <?php selected($availability, 'PreOrder'); ?>>Pre-Order</option>
627 <option value="LimitedAvailability" <?php selected($availability, 'LimitedAvailability'); ?>>Limited Availability</option>
628 </select>
629 </div>
630
631 <div class="schema-field">
632 <label for="product_condition_<?php echo esc_attr((string) $index); ?>">Condition:</label>
633 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][condition]" id="product_condition_<?php echo esc_attr((string) $index); ?>">
634 <option value="NewCondition" <?php selected($condition, 'NewCondition'); ?>>New</option>
635 <option value="UsedCondition" <?php selected($condition, 'UsedCondition'); ?>>Used</option>
636 <option value="RefurbishedCondition" <?php selected($condition, 'RefurbishedCondition'); ?>>Refurbished</option>
637 <option value="DamagedCondition" <?php selected($condition, 'DamagedCondition'); ?>>Damaged</option>
638 </select>
639 </div>
640 </div>
641 <?php
642 }
643
644 /**
645 * Render Recipe schema fields
646 */
647 private function render_recipe_fields($fields, $index = 0)
648 {
649 $yield = isset($fields['yield']) ? $fields['yield'] : '';
650 $ingredients = isset($fields['ingredients']) ? $fields['ingredients'] : [''];
651 $instructions = isset($fields['instructions']) ? $fields['instructions'] : [''];
652 $prep_time = isset($fields['prep_time']) ? $fields['prep_time'] : '';
653 $cook_time = isset($fields['cook_time']) ? $fields['cook_time'] : '';
654 $total_time = isset($fields['total_time']) ? $fields['total_time'] : '';
655 $calories = isset($fields['calories']) ? $fields['calories'] : '';
656
657 ?>
658 <div class="schema-field-group">
659 <?php $this->render_override_fields_section($fields, $index, 'recipe', true); ?>
660
661 <h4>Recipe Information</h4>
662 <p class="description" style="margin-top: 0;">Optional fields to enhance your recipe schema.</p>
663
664 <div class="schema-field">
665 <label for="recipe_yield_<?php echo esc_attr((string) $index); ?>">Yield (servings):</label>
666 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][yield]" id="recipe_yield_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($yield); ?>" placeholder="e.g., 4 servings">
667 </div>
668
669 <div class="schema-field">
670 <label>Ingredients:</label>
671 <div class="ingredients-list">
672 <?php foreach ($ingredients as $ingredient): ?>
673 <div class="ingredient-item">
674 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][ingredients][]" value="<?php echo esc_attr($ingredient); ?>" placeholder="Enter ingredient">
675 <button type="button" class="remove-item">Remove</button>
676 </div>
677 <?php endforeach; ?>
678 </div>
679 <button type="button" class="add-ingredient">Add Ingredient</button>
680 </div>
681
682 <div class="schema-field">
683 <label>Instructions:</label>
684 <div class="instructions-list">
685 <?php foreach ($instructions as $instruction): ?>
686 <div class="instruction-item">
687 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][instructions][]" placeholder="Enter instruction step"><?php echo esc_textarea($instruction); ?></textarea>
688 <button type="button" class="remove-item">Remove</button>
689 </div>
690 <?php endforeach; ?>
691 </div>
692 <button type="button" class="add-instruction">Add Instruction</button>
693 </div>
694
695 <div class="schema-field">
696 <label for="recipe_prep_time_<?php echo esc_attr((string) $index); ?>">Prep Time (minutes):</label>
697 <input type="number" step="any" min="0" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][prep_time]" id="recipe_prep_time_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($prep_time); ?>" placeholder="15" class="recipe-time-input">
698 </div>
699
700 <div class="schema-field">
701 <label for="recipe_cook_time_<?php echo esc_attr((string) $index); ?>">Cook Time (minutes):</label>
702 <input type="number" step="any" min="0" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][cook_time]" id="recipe_cook_time_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($cook_time); ?>" placeholder="30" class="recipe-time-input">
703 </div>
704
705 <div class="schema-field">
706 <label for="recipe_total_time_<?php echo esc_attr((string) $index); ?>">Total Time (minutes):</label>
707 <input type="number" step="any" min="0" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][total_time]" id="recipe_total_time_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($total_time); ?>" placeholder="45" class="recipe-time-input">
708 </div>
709
710 <div class="schema-field">
711 <label for="recipe_calories_<?php echo esc_attr((string) $index); ?>">Calories per serving:</label>
712 <input type="number" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][calories]" id="recipe_calories_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($calories); ?>" placeholder="250">
713 </div>
714 </div>
715 <?php
716 }
717
718 /**
719 * Render LocalBusiness schema fields
720 */
721 private function render_local_business_fields($fields, $index = 0)
722 {
723 $business_name = isset($fields['business_name']) ? $fields['business_name'] : '';
724 $street_address = isset($fields['street_address']) ? $fields['street_address'] : '';
725 $description = isset($fields['description']) ? $fields['description'] : '';
726 $url = isset($fields['url']) ? $fields['url'] : '';
727 $telephone = isset($fields['telephone']) ? $fields['telephone'] : '';
728 $price_range = isset($fields['price_range']) ? $fields['price_range'] : '';
729 $image = isset($fields['image']) ? $fields['image'] : '';
730 $city = isset($fields['city']) ? $fields['city'] : '';
731 $state = isset($fields['state']) ? $fields['state'] : '';
732 $postal_code = isset($fields['postal_code']) ? $fields['postal_code'] : '';
733 $country = isset($fields['country']) ? $fields['country'] : '';
734 $latitude = isset($fields['latitude']) ? $fields['latitude'] : '';
735 $longitude = isset($fields['longitude']) ? $fields['longitude'] : '';
736 $opening_hours = isset($fields['opening_hours']) ? $fields['opening_hours'] : [];
737
738 ?>
739 <div class="schema-field-group">
740 <h4>Business Information</h4>
741 <p class="description" style="margin-top: 0;">Fill in the business details below. Fields marked with * are required.</p>
742
743 <div class="schema-field">
744 <label for="local_business_name_<?php echo esc_attr((string) $index); ?>">Business Name: <span style="color: #dc3232;">*</span></label>
745 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][business_name]" id="local_business_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($business_name); ?>" placeholder="e.g., Joe's Coffee Shop" style="width: 100%;">
746 </div>
747
748 <div class="schema-field">
749 <label for="local_business_street_<?php echo esc_attr((string) $index); ?>">Street Address: <span style="color: #dc3232;">*</span></label>
750 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][street_address]" id="local_business_street_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($street_address); ?>" placeholder="e.g., 123 Main St" style="width: 100%;">
751 </div>
752
753 <div class="schema-field">
754 <label for="local_business_description_<?php echo esc_attr((string) $index); ?>">Description:</label>
755 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][description]" id="local_business_description_<?php echo esc_attr((string) $index); ?>" placeholder="Brief description of the business" style="width: 100%; height: 80px;"><?php echo esc_textarea($description); ?></textarea>
756 </div>
757
758 <div class="schema-field">
759 <label for="local_business_url_<?php echo esc_attr((string) $index); ?>">URL:</label>
760 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][url]" id="local_business_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($url); ?>" placeholder="https://example.com" style="width: 100%;">
761 </div>
762
763 <div class="schema-field">
764 <label for="local_business_telephone_<?php echo esc_attr((string) $index); ?>">Telephone:</label>
765 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][telephone]" id="local_business_telephone_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($telephone); ?>" placeholder="e.g., +1-555-555-5555">
766 </div>
767
768 <div class="schema-field">
769 <label for="local_business_price_range_<?php echo esc_attr((string) $index); ?>">Price Range:</label>
770 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][price_range]" id="local_business_price_range_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($price_range); ?>" placeholder="$$$">
771 </div>
772
773 <div class="schema-field">
774 <label for="local_business_image_<?php echo esc_attr((string) $index); ?>">Image:</label>
775 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][image]" id="local_business_image_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($image); ?>" placeholder="https://example.com/image.jpg" style="width: 100%;">
776 </div>
777
778 <h4>Address Details</h4>
779
780 <div class="schema-field">
781 <label for="local_business_city_<?php echo esc_attr((string) $index); ?>">City:</label>
782 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][city]" id="local_business_city_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($city); ?>" placeholder="e.g., San Francisco">
783 </div>
784
785 <div class="schema-field">
786 <label for="local_business_state_<?php echo esc_attr((string) $index); ?>">State:</label>
787 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][state]" id="local_business_state_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($state); ?>" placeholder="e.g., CA">
788 </div>
789
790 <div class="schema-field">
791 <label for="local_business_postal_code_<?php echo esc_attr((string) $index); ?>">Postal Code:</label>
792 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][postal_code]" id="local_business_postal_code_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($postal_code); ?>" placeholder="e.g., 94105">
793 </div>
794
795 <div class="schema-field">
796 <label for="local_business_country_<?php echo esc_attr((string) $index); ?>">Country:</label>
797 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][country]" id="local_business_country_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($country); ?>" placeholder="e.g., US">
798 </div>
799
800 <h4>Geo Coordinates</h4>
801
802 <div class="schema-field">
803 <label for="local_business_latitude_<?php echo esc_attr((string) $index); ?>">Latitude:</label>
804 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][latitude]" id="local_business_latitude_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($latitude); ?>" placeholder="e.g., 37.7749">
805 </div>
806
807 <div class="schema-field">
808 <label for="local_business_longitude_<?php echo esc_attr((string) $index); ?>">Longitude:</label>
809 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][longitude]" id="local_business_longitude_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($longitude); ?>" placeholder="e.g., -122.4194">
810 </div>
811
812 <h4>Opening Hours</h4>
813 <p class="description">Add opening hours for the business.</p>
814
815 <div class="opening-hours-list">
816 <?php if (!empty($opening_hours)): ?>
817 <?php foreach ($opening_hours as $oh_index => $hours): ?>
818 <div class="opening-hours-item" style="margin: 10px 0; padding: 10px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9;">
819 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][opening_hours][<?php echo esc_attr($oh_index); ?>][day]" value="<?php echo esc_attr($hours['day']); ?>" placeholder="e.g., Monday" style="width: 30%;">
820 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][opening_hours][<?php echo esc_attr($oh_index); ?>][open]" value="<?php echo esc_attr($hours['open']); ?>" placeholder="09:00" style="width: 25%;">
821 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][opening_hours][<?php echo esc_attr($oh_index); ?>][close]" value="<?php echo esc_attr($hours['close']); ?>" placeholder="17:00" style="width: 25%;">
822 <button type="button" class="button remove-item">Remove</button>
823 </div>
824 <?php endforeach; ?>
825 <?php endif; ?>
826 </div>
827 <button type="button" class="button add-opening-hours" data-index="<?php echo esc_attr((string) $index); ?>" style="margin-top: 10px;">Add Opening Hours</button>
828 </div>
829 <?php
830 }
831
832 /**
833 * Render HowTo schema fields
834 */
835 private function render_howto_fields($fields, $index = 0)
836 {
837 $total_time = isset($fields['total_time']) ? $fields['total_time'] : '';
838 $estimated_cost = isset($fields['estimated_cost']) ? $fields['estimated_cost'] : '';
839 $supplies = isset($fields['supplies']) ? $fields['supplies'] : [];
840 $tools = isset($fields['tools']) ? $fields['tools'] : [];
841 $steps = isset($fields['steps']) ? $fields['steps'] : [['instructions' => '', 'image' => '']];
842
843 ?>
844 <div class="schema-field-group">
845 <?php $this->render_override_fields_section($fields, $index, 'howto', true); ?>
846
847 <h4>How-To Information</h4>
848 <p class="description" style="margin-top: 0;">Fill in the how-to details below. At least one step with instructions is required.</p>
849
850 <div class="schema-field">
851 <label for="howto_total_time_<?php echo esc_attr((string) $index); ?>">Total Time (minutes):</label>
852 <input type="number" step="any" min="0" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][total_time]" id="howto_total_time_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($total_time); ?>" placeholder="30" class="recipe-time-input">
853 </div>
854
855 <div class="schema-field">
856 <label for="howto_estimated_cost_<?php echo esc_attr((string) $index); ?>">Estimated Cost:</label>
857 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][estimated_cost]" id="howto_estimated_cost_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($estimated_cost); ?>" placeholder="e.g., 20.00 USD">
858 </div>
859
860 <div class="schema-field">
861 <label>Supplies:</label>
862 <div class="supplies-list">
863 <?php foreach ($supplies as $supply): ?>
864 <div class="supply-item">
865 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][supplies][]" value="<?php echo esc_attr($supply); ?>" placeholder="Enter supply">
866 <button type="button" class="remove-item">Remove</button>
867 </div>
868 <?php endforeach; ?>
869 </div>
870 <button type="button" class="button add-supply" data-index="<?php echo esc_attr((string) $index); ?>" style="margin-top: 5px;">Add Supply</button>
871 </div>
872
873 <div class="schema-field">
874 <label>Tools:</label>
875 <div class="tools-list">
876 <?php foreach ($tools as $tool): ?>
877 <div class="tool-item">
878 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][tools][]" value="<?php echo esc_attr($tool); ?>" placeholder="Enter tool">
879 <button type="button" class="remove-item">Remove</button>
880 </div>
881 <?php endforeach; ?>
882 </div>
883 <button type="button" class="button add-tool" data-index="<?php echo esc_attr((string) $index); ?>" style="margin-top: 5px;">Add Tool</button>
884 </div>
885
886 <h4>Steps</h4>
887 <p class="description">Add at least one step with instructions. Fields marked with * are required.</p>
888
889 <div class="howto-steps-list">
890 <?php foreach ($steps as $step_index => $step): ?>
891 <div class="howto-step-item" style="margin: 15px 0; padding: 15px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9;">
892 <div class="schema-field">
893 <label>Step <?php echo esc_html((string) ($step_index + 1)); ?> Instructions: <span style="color: #dc3232;">*</span></label>
894 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][steps][<?php echo esc_attr((string) $step_index); ?>][instructions]" placeholder="Enter step instructions" style="width: 100%; height: 80px;"><?php echo esc_textarea(isset($step['instructions']) ? $step['instructions'] : ''); ?></textarea>
895 </div>
896 <div class="schema-field">
897 <label>Step <?php echo esc_html((string) ($step_index + 1)); ?> Image (optional):</label>
898 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][steps][<?php echo esc_attr((string) $step_index); ?>][image]" value="<?php echo esc_attr(isset($step['image']) ? $step['image'] : ''); ?>" placeholder="https://example.com/step-image.jpg" style="width: 100%;">
899 </div>
900 <button type="button" class="button remove-howto-step" style="background: #dc3232; color: white; border-color: #dc3232;">Remove Step</button>
901 </div>
902 <?php endforeach; ?>
903 </div>
904 <button type="button" class="button add-howto-step" data-index="<?php echo esc_attr((string) $index); ?>" style="margin-top: 10px;">Add Step</button>
905 </div>
906 <?php
907 }
908
909 /**
910 * Render VideoObject schema fields
911 */
912 private function render_video_object_fields($fields, $index = 0)
913 {
914 $video_name = isset($fields['video_name']) ? $fields['video_name'] : '';
915 $video_description = isset($fields['video_description']) ? $fields['video_description'] : '';
916 $thumbnail_url = isset($fields['thumbnail_url']) ? $fields['thumbnail_url'] : '';
917 $upload_date = isset($fields['upload_date']) ? $fields['upload_date'] : '';
918 $content_url = isset($fields['content_url']) ? $fields['content_url'] : '';
919 $embed_url = isset($fields['embed_url']) ? $fields['embed_url'] : '';
920 $duration = isset($fields['duration']) ? $fields['duration'] : '';
921
922 ?>
923 <div class="schema-field-group">
924 <h4>Video Information</h4>
925 <p class="description" style="margin-top: 0;">Fill in the video details below. Fields marked with * are required.</p>
926
927 <div class="schema-field">
928 <label for="video_name_<?php echo esc_attr((string) $index); ?>">Video Name: <span style="color: #dc3232;">*</span></label>
929 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][video_name]" id="video_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($video_name); ?>" placeholder="e.g., How to Bake a Cake" style="width: 100%;">
930 </div>
931
932 <div class="schema-field">
933 <label for="video_description_<?php echo esc_attr((string) $index); ?>">Video Description: <span style="color: #dc3232;">*</span></label>
934 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][video_description]" id="video_description_<?php echo esc_attr((string) $index); ?>" placeholder="Brief description of the video" style="width: 100%; height: 80px;"><?php echo esc_textarea($video_description); ?></textarea>
935 </div>
936
937 <div class="schema-field">
938 <label for="video_thumbnail_url_<?php echo esc_attr((string) $index); ?>">Thumbnail URL: <span style="color: #dc3232;">*</span></label>
939 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][thumbnail_url]" id="video_thumbnail_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($thumbnail_url); ?>" placeholder="https://example.com/thumbnail.jpg" style="width: 100%;">
940 </div>
941
942 <div class="schema-field">
943 <label for="video_upload_date_<?php echo esc_attr((string) $index); ?>">Upload Date: <span style="color: #dc3232;">*</span></label>
944 <input type="date" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][upload_date]" id="video_upload_date_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($upload_date); ?>">
945 </div>
946
947 <div class="schema-field">
948 <label for="video_content_url_<?php echo esc_attr((string) $index); ?>">Content URL:</label>
949 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][content_url]" id="video_content_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($content_url); ?>" placeholder="https://example.com/video.mp4" style="width: 100%;">
950 <p class="description">Direct URL to the video file.</p>
951 </div>
952
953 <div class="schema-field">
954 <label for="video_embed_url_<?php echo esc_attr((string) $index); ?>">Embed URL:</label>
955 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][embed_url]" id="video_embed_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($embed_url); ?>" placeholder="https://www.youtube.com/embed/xxxxx" style="width: 100%;">
956 <p class="description">URL for embedding the video (e.g., YouTube embed URL).</p>
957 </div>
958
959 <div class="schema-field">
960 <label for="video_duration_<?php echo esc_attr((string) $index); ?>">Duration (minutes):</label>
961 <input type="number" step="any" min="0" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][duration]" id="video_duration_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($duration); ?>" placeholder="10" class="recipe-time-input">
962 </div>
963 </div>
964 <?php
965 }
966
967 /**
968 * Save schema markup data
969 */
970 public function save_schema_markup_data($post_id)
971 {
972 // Do not process revisions or autosaves. Revision metadata is redirected
973 // to the parent post and can otherwise replace its schema with empty data.
974 if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
975 return;
976 }
977
978 // Check if user has permission to edit the post
979 if (!current_user_can('edit_post', $post_id)) {
980 return;
981 }
982
983 // Verify nonce
984 $schema_nonce = isset($_POST['metasync_schema_markup_nonce']) ? sanitize_text_field(wp_unslash($_POST['metasync_schema_markup_nonce'])) : '';
985 if (!wp_verify_nonce($schema_nonce, 'metasync_schema_markup_nonce')) {
986 return;
987 }
988
989 // Check if this is an autosave
990 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
991 return;
992 }
993
994 // Get existing schema data to preserve if needed
995 $existing_schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
996
997 // Start from the stored value because OTTO owns otto_jsonld while this
998 // form owns enabled/types. Updating one owner must not erase the other.
999 $schema_data = is_array($existing_schema_data) ? $existing_schema_data : [];
1000 $schema_data['enabled'] = false;
1001 $schema_data['types'] = [];
1002
1003 if (isset($_POST['schema_markup'])) {
1004 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised below via the recursive Metasync_Common::sanitize_array() (sanitize_url/sanitize_text_field per leaf).
1005 $post_data = $this->common->sanitize_array(wp_unslash($_POST['schema_markup']));
1006
1007 $schema_data['enabled'] = isset($post_data['enabled']) ? (bool)$post_data['enabled'] : false;
1008 $schema_data['types'] = [];
1009
1010 // Process multiple schema types and ensure uniqueness
1011 if (isset($post_data['types']) && is_array($post_data['types'])) {
1012 $seen_types = [];
1013 foreach ($post_data['types'] as $type_data) {
1014 if (!empty($type_data['type'])) {
1015 $schema_type = sanitize_text_field($type_data['type']);
1016
1017 // Skip if this schema type already exists (keep first occurrence)
1018 if (in_array($schema_type, $seen_types)) {
1019 continue;
1020 }
1021
1022 $schema_fields = isset($type_data['fields']) ? $this->sanitize_schema_fields($type_data['fields'], $schema_type) : [];
1023
1024 $schema_data['types'][] = [
1025 'type' => $schema_type,
1026 'fields' => $schema_fields
1027 ];
1028
1029 // Mark this type as seen
1030 $seen_types[] = $schema_type;
1031 }
1032 }
1033 }
1034 }
1035
1036 // If checkbox is unchecked, preserve existing schema data but mark as disabled
1037 if (empty($schema_data['enabled'])) {
1038 if (!empty($existing_schema_data) && !empty($existing_schema_data['types'])) {
1039 // Preserve existing schema types but mark as disabled
1040 $schema_data['enabled'] = false;
1041 $schema_data['types'] = $existing_schema_data['types'];
1042 }
1043 }
1044
1045 // Delete any existing validation errors
1046 delete_post_meta($post_id, '_metasync_schema_validation_errors');
1047
1048 // Save the meta (only validate if enabled)
1049 if (!empty($schema_data['enabled']) && !empty($schema_data['types'])) {
1050 // Validate schema requirements for all types
1051 $all_validation_errors = [];
1052
1053 foreach ($schema_data['types'] as $schema_type_data) {
1054 $validation_errors = $this->validate_schema_requirements($post_id, $schema_type_data['type'], $schema_type_data['fields']);
1055 if (!empty($validation_errors)) {
1056 $all_validation_errors = array_merge($all_validation_errors, $validation_errors);
1057 }
1058 }
1059
1060 if (!empty($all_validation_errors)) {
1061 // Store validation errors as post meta
1062 update_post_meta($post_id, '_metasync_schema_validation_errors', $all_validation_errors);
1063
1064 // Set a transient to show admin notice immediately after save
1065 set_transient('metasync_schema_validation_error_' . $post_id, $all_validation_errors, 45);
1066 }
1067 }
1068
1069 // WordPress unslashes metadata before serialization; slash the complete
1070 // value so escaped JSON inside OTTO's persistent copy stays valid.
1071 update_post_meta($post_id, 'metasync_schema_markup', wp_slash($schema_data));
1072
1073 // Cross-plugin sync for each schema type
1074 if (!empty($schema_data['enabled']) && !empty($schema_data['types'])) {
1075 foreach ($schema_data['types'] as $schema_type_data) {
1076 $this->sync_schema_to_seo_plugins($post_id, $schema_type_data['type'], $schema_type_data['fields']);
1077 }
1078 }
1079 }
1080
1081 /**
1082 * Validate schema requirements based on type
1083 *
1084 * @param int $post_id The post ID
1085 * @param string $type Schema type
1086 * @param array $fields Schema fields
1087 * @return array Array of validation errors (empty if valid)
1088 */
1089 public function validate_schema_requirements($post_id, $type, $fields)
1090 {
1091 $errors = [];
1092
1093 switch ($type) {
1094 case 'article':
1095 $errors = $this->validate_article_schema($post_id, $fields);
1096 break;
1097 // Add more schema type validations here in the future
1098 case 'product':
1099 $errors = $this->validate_product_schema($post_id, $fields);
1100 break;
1101 case 'recipe':
1102 $errors = $this->validate_recipe_schema($post_id, $fields);
1103 break;
1104 case 'FAQPage':
1105 $errors = $this->validate_faq_schema($post_id, $fields);
1106 break;
1107 case 'Event':
1108 $errors = $this->validate_event_schema($post_id, $fields);
1109 break;
1110 case 'JobPosting':
1111 $errors = $this->validate_jobposting_schema($post_id, $fields);
1112 break;
1113 case 'Review':
1114 $errors = $this->validate_review_schema($post_id, $fields);
1115 break;
1116 case 'Course':
1117 $errors = $this->validate_course_schema($post_id, $fields);
1118 break;
1119 case 'Organization':
1120 $errors = $this->validate_organization_schema($post_id, $fields);
1121 break;
1122 case 'Person':
1123 $errors = $this->validate_person_schema($post_id, $fields);
1124 break;
1125 case 'WebSite':
1126 $errors = $this->validate_website_schema($post_id, $fields);
1127 break;
1128 case 'NewsArticle':
1129 $errors = $this->validate_newsarticle_schema($post_id, $fields);
1130 break;
1131 case 'LocalBusiness':
1132 $errors = $this->validate_local_business_schema($post_id, $fields);
1133 break;
1134 case 'HowTo':
1135 $errors = $this->validate_howto_schema($post_id, $fields);
1136 break;
1137 case 'VideoObject':
1138 $errors = $this->validate_video_object_schema($post_id, $fields);
1139 break;
1140 }
1141
1142 return $errors;
1143 }
1144
1145 /**
1146 * Validate Article schema requirements
1147 * Article requires: Headline, Description, and Image
1148 *
1149 * @param int $post_id The post ID
1150 * @param array $fields Schema fields
1151 * @return array Array of validation errors
1152 */
1153 private function validate_article_schema($post_id, $fields)
1154 {
1155 $errors = [];
1156
1157 // Get effective values (override if provided, otherwise defaults)
1158 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
1159 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
1160 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
1161
1162 // 1. Validate Headline (Title)
1163 if (empty($effective_title) || trim($effective_title) === '') {
1164 $errors[] = [
1165 'field' => 'headline',
1166 'message' => 'Article schema requires a <strong>Headline</strong>. Please add a post title or override it in the Override Default Fields section.'
1167 ];
1168 }
1169
1170 // 2. Validate Description
1171 if (empty($effective_description) || trim($effective_description) === '') {
1172 $errors[] = [
1173 'field' => 'description',
1174 'message' => 'Article schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
1175 ];
1176 }
1177
1178 // 3. Validate Image
1179 if (empty($effective_image)) {
1180 $errors[] = [
1181 'field' => 'image',
1182 'message' => 'Article schema requires an <strong>Image</strong>. Please set a featured image for this post or override it in the Override Default Fields section.'
1183 ];
1184 }
1185
1186 return $errors;
1187 }
1188
1189 /**
1190 * Validate Product schema requirements
1191 * Product requires: Name, Description, Image, and Price
1192 *
1193 * @param int $post_id The post ID
1194 * @param array $fields Schema fields
1195 * @return array Array of validation errors
1196 */
1197 private function validate_product_schema($post_id, $fields)
1198 {
1199 $errors = [];
1200
1201 // Get effective values (override if provided, otherwise defaults)
1202 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
1203 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
1204 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
1205
1206 // 1. Validate Name (Title)
1207 if (empty($effective_title) || trim($effective_title) === '') {
1208 $errors[] = [
1209 'field' => 'name',
1210 'message' => 'Product schema requires a <strong>Name</strong>. Please add a post title or override it in the Override Default Fields section.'
1211 ];
1212 }
1213
1214 // 2. Validate Description
1215 if (empty($effective_description) || trim($effective_description) === '') {
1216 $errors[] = [
1217 'field' => 'description',
1218 'message' => 'Product schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
1219 ];
1220 }
1221
1222 // 3. Validate Image
1223 if (empty($effective_image)) {
1224 $errors[] = [
1225 'field' => 'image',
1226 'message' => 'Product schema requires an <strong>Image</strong>. Please set a featured image for this post or override it in the Override Default Fields section.'
1227 ];
1228 }
1229
1230 // 4. Validate Price
1231 if (empty($fields['price']) || floatval($fields['price']) <= 0) {
1232 $errors[] = [
1233 'field' => 'price',
1234 'message' => 'Product schema requires a valid <strong>Price</strong>. Please enter a price greater than 0.'
1235 ];
1236 }
1237
1238 return $errors;
1239 }
1240
1241 /**
1242 * Validate Recipe schema requirements
1243 * Recipe requires: Name, Description, and Image
1244 *
1245 * @param int $post_id The post ID
1246 * @param array $fields Schema fields
1247 * @return array Array of validation errors
1248 */
1249 private function validate_recipe_schema($post_id, $fields)
1250 {
1251 $errors = [];
1252
1253 // Get effective values (override if provided, otherwise defaults)
1254 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
1255 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
1256 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
1257
1258 // 1. Validate Name (Title)
1259 if (empty($effective_title) || trim($effective_title) === '') {
1260 $errors[] = [
1261 'field' => 'name',
1262 'message' => 'Recipe schema requires a <strong>Name</strong>. Please add a post title or override it in the Override Default Fields section.'
1263 ];
1264 }
1265
1266 // 2. Validate Description
1267 if (empty($effective_description) || trim($effective_description) === '') {
1268 $errors[] = [
1269 'field' => 'description',
1270 'message' => 'Recipe schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
1271 ];
1272 }
1273
1274 // 3. Validate Image
1275 if (empty($effective_image)) {
1276 $errors[] = [
1277 'field' => 'image',
1278 'message' => 'Recipe schema requires an <strong>Image</strong>. Please set a featured image for this post or override it in the Override Default Fields section.'
1279 ];
1280 }
1281
1282 return $errors;
1283 }
1284
1285 /**
1286 * Validate FAQ schema requirements
1287 * FAQ requires at least one Q&A pair with non-empty question and answer
1288 *
1289 * @param int $post_id The post ID
1290 * @param array $fields Schema fields
1291 * @return array Array of validation errors
1292 */
1293 private function validate_faq_schema($post_id, $fields)
1294 {
1295 $errors = [];
1296
1297 // Check if FAQ items exist
1298 if (empty($fields['faq_items']) || !is_array($fields['faq_items'])) {
1299 $errors[] = [
1300 'field' => 'faq_items',
1301 'message' => 'FAQ schema requires at least one <strong>Question & Answer</strong> pair. Please add at least one FAQ item.'
1302 ];
1303 return $errors;
1304 }
1305
1306 // Validate each FAQ item
1307 $has_valid_item = false;
1308 foreach ($fields['faq_items'] as $index => $item) {
1309 $question = isset($item['question']) ? trim($item['question']) : '';
1310 $answer = isset($item['answer']) ? trim($item['answer']) : '';
1311
1312 // Check if both question and answer are provided
1313 if (!empty($question) && !empty($answer)) {
1314 $has_valid_item = true;
1315 } elseif (!empty($question) || !empty($answer)) {
1316 // Partial entry - one is filled but not the other
1317 $item_number = $index + 1;
1318 if (empty($question)) {
1319 $errors[] = [
1320 'field' => 'faq_items',
1321 'message' => "FAQ item #{$item_number}: <strong>Question</strong> cannot be left blank."
1322 ];
1323 }
1324 if (empty($answer)) {
1325 $errors[] = [
1326 'field' => 'faq_items',
1327 'message' => "FAQ item #{$item_number}: <strong>Answer</strong> cannot be left blank."
1328 ];
1329 }
1330 }
1331 }
1332
1333 // Check if at least one valid Q&A pair exists
1334 if (!$has_valid_item) {
1335 $errors[] = [
1336 'field' => 'faq_items',
1337 'message' => 'FAQ schema requires at least one complete <strong>Question & Answer</strong> pair. Please fill in both question and answer for at least one FAQ item.'
1338 ];
1339 }
1340
1341 return $errors;
1342 }
1343
1344 /**
1345 * Validate LocalBusiness schema requirements
1346 */
1347 private function validate_local_business_schema($post_id, $fields)
1348 {
1349 $errors = [];
1350
1351 if (empty($fields['business_name']) || trim($fields['business_name']) === '') {
1352 $errors[] = [
1353 'field' => 'business_name',
1354 'message' => 'LocalBusiness schema requires a <strong>Business Name</strong>.'
1355 ];
1356 }
1357
1358 if (empty($fields['street_address']) || trim($fields['street_address']) === '') {
1359 $errors[] = [
1360 'field' => 'street_address',
1361 'message' => 'LocalBusiness schema requires a <strong>Street Address</strong>.'
1362 ];
1363 }
1364
1365 return $errors;
1366 }
1367
1368 /**
1369 * Validate HowTo schema requirements
1370 */
1371 private function validate_howto_schema($post_id, $fields)
1372 {
1373 $errors = [];
1374
1375 // Validate override fields (title, description, image)
1376 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
1377 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
1378 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
1379
1380 if (empty($effective_title) || trim($effective_title) === '') {
1381 $errors[] = [
1382 'field' => 'name',
1383 'message' => 'How-To schema requires a <strong>Name</strong>. Please add a post title or override it in the Override Default Fields section.'
1384 ];
1385 }
1386
1387 if (empty($effective_description) || trim($effective_description) === '') {
1388 $errors[] = [
1389 'field' => 'description',
1390 'message' => 'How-To schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
1391 ];
1392 }
1393
1394 if (empty($effective_image)) {
1395 $errors[] = [
1396 'field' => 'image',
1397 'message' => 'How-To schema requires an <strong>Image</strong>. Please set a featured image for this post or override it in the Override Default Fields section.'
1398 ];
1399 }
1400
1401 // Validate at least one step with non-empty instructions
1402 $has_valid_step = false;
1403 if (!empty($fields['steps']) && is_array($fields['steps'])) {
1404 foreach ($fields['steps'] as $step) {
1405 if (!empty($step['instructions']) && trim($step['instructions']) !== '') {
1406 $has_valid_step = true;
1407 break;
1408 }
1409 }
1410 }
1411
1412 if (!$has_valid_step) {
1413 $errors[] = [
1414 'field' => 'steps',
1415 'message' => 'How-To schema requires at least one <strong>Step</strong> with instructions.'
1416 ];
1417 }
1418
1419 return $errors;
1420 }
1421
1422 /**
1423 * Validate VideoObject schema requirements
1424 */
1425 private function validate_video_object_schema($post_id, $fields)
1426 {
1427 $errors = [];
1428
1429 if (empty($fields['video_name']) || trim($fields['video_name']) === '') {
1430 $errors[] = [
1431 'field' => 'video_name',
1432 'message' => 'VideoObject schema requires a <strong>Video Name</strong>.'
1433 ];
1434 }
1435
1436 if (empty($fields['video_description']) || trim($fields['video_description']) === '') {
1437 $errors[] = [
1438 'field' => 'video_description',
1439 'message' => 'VideoObject schema requires a <strong>Video Description</strong>.'
1440 ];
1441 }
1442
1443 if (empty($fields['thumbnail_url']) || trim($fields['thumbnail_url']) === '') {
1444 $errors[] = [
1445 'field' => 'thumbnail_url',
1446 'message' => 'VideoObject schema requires a <strong>Thumbnail URL</strong>.'
1447 ];
1448 }
1449
1450 if (empty($fields['upload_date']) || trim($fields['upload_date']) === '') {
1451 $errors[] = [
1452 'field' => 'upload_date',
1453 'message' => 'VideoObject schema requires an <strong>Upload Date</strong>.'
1454 ];
1455 }
1456
1457 return $errors;
1458 }
1459
1460 /**
1461 * Display schema validation notices in admin
1462 */
1463 public function display_schema_validation_notices()
1464 {
1465 // Only show on post edit screens
1466 if ( ! function_exists( 'get_current_screen' ) ) {
1467 return;
1468 }
1469 $screen = get_current_screen();
1470 if (!$screen || ($screen->base !== 'post' && $screen->base !== 'post-new')) {
1471 return;
1472 }
1473
1474 // Get current post ID
1475 global $post;
1476 if (!$post || !isset($post->ID)) {
1477 return;
1478 }
1479
1480 $post_id = $post->ID;
1481
1482 // Check for validation errors from transient (immediately after save)
1483 $errors = get_transient('metasync_schema_validation_error_' . $post_id);
1484
1485 // If no transient, check post meta (for persistent errors)
1486 if ($errors === false) {
1487 $errors = get_post_meta($post_id, '_metasync_schema_validation_errors', true);
1488 } else {
1489 // Delete the transient after displaying once
1490 delete_transient('metasync_schema_validation_error_' . $post_id);
1491 }
1492
1493 if (empty($errors) || !is_array($errors)) {
1494 return;
1495 }
1496
1497 // Get schema type for context
1498 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
1499 $schema_type = isset($schema_data['type']) ? ucfirst($schema_data['type']) : 'Schema';
1500
1501 ?>
1502 <div class="notice notice-error is-dismissible metasync-schema-validation-notice">
1503 <h3 style="margin: 0.5em 0;">⚠️ <?php echo esc_html($schema_type); ?> Schema Validation Errors</h3>
1504 <p><strong>The following required fields are missing:</strong></p>
1505 <ul style="list-style: disc; margin-left: 20px;">
1506 <?php foreach ($errors as $error): ?>
1507 <li><?php echo wp_kses_post($error['message']); ?></li>
1508 <?php endforeach; ?>
1509 </ul>
1510 <p><em>Please fix these issues to ensure your schema markup is valid and can be properly indexed by search engines.</em></p>
1511 </div>
1512 <?php
1513 }
1514
1515 /**
1516 * Sanitize schema fields based on type
1517 */
1518 public function sanitize_schema_fields($fields, $type)
1519 {
1520 $sanitized = [];
1521
1522 // Sanitize override fields (common to all types that support them)
1523 // Store placeholders as-is in database, will be replaced during JSON generation
1524 $sanitized['title_override'] = isset($fields['title_override']) ? sanitize_text_field($fields['title_override']) : '';
1525 $sanitized['description_override'] = isset($fields['description_override']) ? sanitize_textarea_field($fields['description_override']) : '';
1526
1527 // For image_override, check if it's a placeholder before sanitizing as URL
1528 $image_override_raw = isset($fields['image_override']) ? trim($fields['image_override']) : '';
1529 if ($this->is_placeholder($image_override_raw)) {
1530 // Store placeholder as-is
1531 $sanitized['image_override'] = $image_override_raw;
1532 } else {
1533 // Sanitize as URL only if it's not a placeholder
1534 $sanitized['image_override'] = !empty($image_override_raw) ? esc_url_raw($image_override_raw) : '';
1535 }
1536
1537 switch ($type) {
1538 case 'article':
1539 $sanitized['organization_name'] = isset($fields['organization_name']) ? sanitize_text_field($fields['organization_name']) : '';
1540 $sanitized['organization_logo'] = isset($fields['organization_logo']) ? esc_url_raw($fields['organization_logo']) : '';
1541 break;
1542 case 'FAQPage':
1543 // FAQ doesn't use override fields, so remove them
1544 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1545 $sanitized['faq_items'] = [];
1546 if (isset($fields['faq_items']) && is_array($fields['faq_items'])) {
1547 foreach ($fields['faq_items'] as $item) {
1548 if (!empty($item['question']) && !empty($item['answer'])) {
1549 $sanitized['faq_items'][] = [
1550 'question' => sanitize_text_field($item['question']),
1551 'answer' => sanitize_textarea_field($item['answer'])
1552 ];
1553 }
1554 }
1555 }
1556 break;
1557 case 'product':
1558 $sanitized['sku'] = isset($fields['sku']) ? sanitize_text_field($fields['sku']) : '';
1559 $sanitized['brand'] = isset($fields['brand']) ? sanitize_text_field($fields['brand']) : '';
1560 $sanitized['price'] = isset($fields['price']) ? floatval($fields['price']) : 0;
1561 $sanitized['currency'] = isset($fields['currency']) ? sanitize_text_field($fields['currency']) : 'USD';
1562 $sanitized['availability'] = isset($fields['availability']) ? sanitize_text_field($fields['availability']) : 'InStock';
1563 $sanitized['condition'] = isset($fields['condition']) ? sanitize_text_field($fields['condition']) : 'NewCondition';
1564 break;
1565
1566 case 'recipe':
1567 $sanitized['yield'] = isset($fields['yield']) ? sanitize_text_field($fields['yield']) : '';
1568 $sanitized['ingredients'] = isset($fields['ingredients']) ? array_map('sanitize_text_field', array_filter($fields['ingredients'])) : [];
1569 $sanitized['instructions'] = isset($fields['instructions']) ? array_map('sanitize_textarea_field', array_filter($fields['instructions'])) : [];
1570 $sanitized['prep_time'] = isset($fields['prep_time']) ? intval($fields['prep_time']) : 0;
1571 $sanitized['cook_time'] = isset($fields['cook_time']) ? intval($fields['cook_time']) : 0;
1572 $sanitized['total_time'] = isset($fields['total_time']) ? intval($fields['total_time']) : 0;
1573 $sanitized['calories'] = isset($fields['calories']) ? intval($fields['calories']) : 0;
1574 break;
1575
1576 case 'Event':
1577 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1578 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1579 $sanitized['startDate'] = isset($fields['startDate']) ? sanitize_text_field($fields['startDate']) : '';
1580 $sanitized['endDate'] = isset($fields['endDate']) ? sanitize_text_field($fields['endDate']) : '';
1581 $sanitized['eventStatus'] = isset($fields['eventStatus']) ? sanitize_text_field($fields['eventStatus']) : 'EventScheduled';
1582 $sanitized['eventAttendanceMode'] = isset($fields['eventAttendanceMode']) ? sanitize_text_field($fields['eventAttendanceMode']) : 'OfflineEventAttendanceMode';
1583 $sanitized['location_name'] = isset($fields['location_name']) ? sanitize_text_field($fields['location_name']) : '';
1584 $sanitized['location_address'] = isset($fields['location_address']) ? sanitize_text_field($fields['location_address']) : '';
1585 $sanitized['organizer_name'] = isset($fields['organizer_name']) ? sanitize_text_field($fields['organizer_name']) : '';
1586 $sanitized['organizer_url'] = isset($fields['organizer_url']) ? esc_url_raw($fields['organizer_url']) : '';
1587 $sanitized['offer_price'] = isset($fields['offer_price']) ? sanitize_text_field($fields['offer_price']) : '';
1588 $sanitized['offer_priceCurrency'] = isset($fields['offer_priceCurrency']) ? sanitize_text_field($fields['offer_priceCurrency']) : 'USD';
1589 $sanitized['offer_availability'] = isset($fields['offer_availability']) ? sanitize_text_field($fields['offer_availability']) : 'InStock';
1590 $sanitized['offer_url'] = isset($fields['offer_url']) ? esc_url_raw($fields['offer_url']) : '';
1591 $sanitized['image'] = isset($fields['image']) ? esc_url_raw($fields['image']) : '';
1592 $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : '';
1593 break;
1594
1595 case 'JobPosting':
1596 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1597 $sanitized['title'] = isset($fields['title']) ? sanitize_text_field($fields['title']) : '';
1598 $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : '';
1599 $sanitized['datePosted'] = isset($fields['datePosted']) ? sanitize_text_field($fields['datePosted']) : '';
1600 $sanitized['validThrough'] = isset($fields['validThrough']) ? sanitize_text_field($fields['validThrough']) : '';
1601 $sanitized['employmentType'] = isset($fields['employmentType']) ? sanitize_text_field($fields['employmentType']) : 'FULL_TIME';
1602 $sanitized['hiringOrganization_name'] = isset($fields['hiringOrganization_name']) ? sanitize_text_field($fields['hiringOrganization_name']) : '';
1603 $sanitized['hiringOrganization_sameAs'] = isset($fields['hiringOrganization_sameAs']) ? esc_url_raw($fields['hiringOrganization_sameAs']) : '';
1604 $sanitized['hiringOrganization_logo'] = isset($fields['hiringOrganization_logo']) ? esc_url_raw($fields['hiringOrganization_logo']) : '';
1605 $sanitized['jobLocation_address'] = isset($fields['jobLocation_address']) ? sanitize_text_field($fields['jobLocation_address']) : '';
1606 $sanitized['baseSalary_currency'] = isset($fields['baseSalary_currency']) ? sanitize_text_field($fields['baseSalary_currency']) : '';
1607 $sanitized['baseSalary_value'] = isset($fields['baseSalary_value']) ? floatval($fields['baseSalary_value']) : 0;
1608 $sanitized['baseSalary_unitText'] = isset($fields['baseSalary_unitText']) ? sanitize_text_field($fields['baseSalary_unitText']) : 'YEAR';
1609 break;
1610
1611 case 'Review':
1612 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1613 $sanitized['itemReviewed_name'] = isset($fields['itemReviewed_name']) ? sanitize_text_field($fields['itemReviewed_name']) : '';
1614 $sanitized['reviewRating_ratingValue'] = isset($fields['reviewRating_ratingValue']) ? floatval($fields['reviewRating_ratingValue']) : 0;
1615 $sanitized['reviewRating_bestRating'] = isset($fields['reviewRating_bestRating']) ? floatval($fields['reviewRating_bestRating']) : 5;
1616 $sanitized['author_name'] = isset($fields['author_name']) ? sanitize_text_field($fields['author_name']) : '';
1617 $sanitized['reviewBody'] = isset($fields['reviewBody']) ? sanitize_textarea_field($fields['reviewBody']) : '';
1618 break;
1619
1620 case 'Course':
1621 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1622 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1623 $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : '';
1624 $sanitized['provider_name'] = isset($fields['provider_name']) ? sanitize_text_field($fields['provider_name']) : '';
1625 $sanitized['provider_sameAs'] = isset($fields['provider_sameAs']) ? esc_url_raw($fields['provider_sameAs']) : '';
1626 $sanitized['courseInstance_courseMode'] = isset($fields['courseInstance_courseMode']) ? sanitize_text_field($fields['courseInstance_courseMode']) : 'online';
1627 $sanitized['courseInstance_instructor_name'] = isset($fields['courseInstance_instructor_name']) ? sanitize_text_field($fields['courseInstance_instructor_name']) : '';
1628 break;
1629
1630 case 'Organization':
1631 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1632 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1633 $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : '';
1634 $sanitized['logo'] = isset($fields['logo']) ? esc_url_raw($fields['logo']) : '';
1635 $sanitized['contactPoint_telephone'] = isset($fields['contactPoint_telephone']) ? sanitize_text_field($fields['contactPoint_telephone']) : '';
1636 $sanitized['contactPoint_contactType'] = isset($fields['contactPoint_contactType']) ? sanitize_text_field($fields['contactPoint_contactType']) : '';
1637 $sanitized['sameAs'] = isset($fields['sameAs']) ? sanitize_textarea_field($fields['sameAs']) : '';
1638 break;
1639
1640 case 'Person':
1641 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1642 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1643 $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : '';
1644 $sanitized['jobTitle'] = isset($fields['jobTitle']) ? sanitize_text_field($fields['jobTitle']) : '';
1645 $sanitized['worksFor'] = isset($fields['worksFor']) ? sanitize_text_field($fields['worksFor']) : '';
1646 $sanitized['email'] = isset($fields['email']) ? sanitize_email($fields['email']) : '';
1647 $sanitized['image'] = isset($fields['image']) ? esc_url_raw($fields['image']) : '';
1648 $sanitized['sameAs'] = isset($fields['sameAs']) ? sanitize_textarea_field($fields['sameAs']) : '';
1649 break;
1650
1651 case 'WebSite':
1652 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1653 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1654 $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : '';
1655 $sanitized['searchbox_enabled'] = isset($fields['searchbox_enabled']) ? (bool) $fields['searchbox_enabled'] : false;
1656 $sanitized['searchbox_query_input'] = isset($fields['searchbox_query_input']) ? sanitize_text_field($fields['searchbox_query_input']) : '';
1657 break;
1658
1659 case 'NewsArticle':
1660 $sanitized['organization_name'] = isset($fields['organization_name']) ? sanitize_text_field($fields['organization_name']) : '';
1661 $sanitized['organization_logo'] = isset($fields['organization_logo']) ? esc_url_raw($fields['organization_logo']) : '';
1662 break;
1663
1664 case 'LocalBusiness':
1665 // Self-managed type, remove override fields
1666 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1667 $sanitized['business_name'] = isset($fields['business_name']) ? sanitize_text_field($fields['business_name']) : '';
1668 $sanitized['street_address'] = isset($fields['street_address']) ? sanitize_text_field($fields['street_address']) : '';
1669 $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : '';
1670 $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : '';
1671 $sanitized['telephone'] = isset($fields['telephone']) ? sanitize_text_field($fields['telephone']) : '';
1672 $sanitized['price_range'] = isset($fields['price_range']) ? sanitize_text_field($fields['price_range']) : '';
1673 $sanitized['image'] = isset($fields['image']) ? esc_url_raw($fields['image']) : '';
1674 $sanitized['city'] = isset($fields['city']) ? sanitize_text_field($fields['city']) : '';
1675 $sanitized['state'] = isset($fields['state']) ? sanitize_text_field($fields['state']) : '';
1676 $sanitized['postal_code'] = isset($fields['postal_code']) ? sanitize_text_field($fields['postal_code']) : '';
1677 $sanitized['country'] = isset($fields['country']) ? sanitize_text_field($fields['country']) : '';
1678 $sanitized['latitude'] = isset($fields['latitude']) ? sanitize_text_field($fields['latitude']) : '';
1679 $sanitized['longitude'] = isset($fields['longitude']) ? sanitize_text_field($fields['longitude']) : '';
1680 $sanitized['opening_hours'] = [];
1681 if (isset($fields['opening_hours']) && is_array($fields['opening_hours'])) {
1682 foreach ($fields['opening_hours'] as $hours) {
1683 if (!empty($hours['day'])) {
1684 $sanitized['opening_hours'][] = [
1685 'day' => sanitize_text_field($hours['day']),
1686 'open' => sanitize_text_field($hours['open']),
1687 'close' => sanitize_text_field($hours['close']),
1688 ];
1689 }
1690 }
1691 }
1692 break;
1693
1694 case 'HowTo':
1695 $sanitized['total_time'] = isset($fields['total_time']) ? intval($fields['total_time']) : 0;
1696 $sanitized['estimated_cost'] = isset($fields['estimated_cost']) ? sanitize_text_field($fields['estimated_cost']) : '';
1697 $sanitized['supplies'] = isset($fields['supplies']) ? array_map('sanitize_text_field', array_filter($fields['supplies'])) : [];
1698 $sanitized['tools'] = isset($fields['tools']) ? array_map('sanitize_text_field', array_filter($fields['tools'])) : [];
1699 $sanitized['steps'] = [];
1700 if (isset($fields['steps']) && is_array($fields['steps'])) {
1701 foreach ($fields['steps'] as $step) {
1702 $sanitized['steps'][] = [
1703 'instructions' => isset($step['instructions']) ? sanitize_textarea_field($step['instructions']) : '',
1704 'image' => isset($step['image']) ? esc_url_raw($step['image']) : '',
1705 ];
1706 }
1707 }
1708 break;
1709
1710 case 'VideoObject':
1711 // Self-managed type, remove override fields
1712 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1713 $sanitized['video_name'] = isset($fields['video_name']) ? sanitize_text_field($fields['video_name']) : '';
1714 $sanitized['video_description'] = isset($fields['video_description']) ? sanitize_textarea_field($fields['video_description']) : '';
1715 $sanitized['thumbnail_url'] = isset($fields['thumbnail_url']) ? esc_url_raw($fields['thumbnail_url']) : '';
1716 $sanitized['upload_date'] = isset($fields['upload_date']) ? sanitize_text_field($fields['upload_date']) : '';
1717 $sanitized['content_url'] = isset($fields['content_url']) ? esc_url_raw($fields['content_url']) : '';
1718 $sanitized['embed_url'] = isset($fields['embed_url']) ? esc_url_raw($fields['embed_url']) : '';
1719 $sanitized['duration'] = isset($fields['duration']) ? intval($fields['duration']) : 0;
1720 break;
1721 }
1722
1723 return $sanitized;
1724 }
1725
1726 /**
1727 * Output schema markup to the frontend
1728 */
1729 public function output_schema_markup()
1730 {
1731 // Schema Markup switched off. This covers every JSON-LD source resolved
1732 // below — global Organization/WebSite nodes, the per-post meta box types,
1733 // live OTTO structured data and the DB-persisted OTTO payload.
1734 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
1735 return;
1736 }
1737
1738 $all_json_ld = [];
1739
1740 // Holds the DB-persisted OTTO JSON-LD string when OTTO is off (output as its
1741 // own tag below). Stays null while OTTO is live to avoid the duplicate.
1742 $otto_persisted_jsonld = null;
1743
1744 // ---- Global schema: WebSite on front page, Organization on all pages ----
1745 $metasync_options = get_option('metasync_options', []);
1746 $schema_settings = isset($metasync_options['schema']) ? $metasync_options['schema'] : [];
1747
1748 // Organization schema (site-wide)
1749 if (!empty($schema_settings['org_name'])) {
1750 if ($this->should_output_schema_type('Organization')) {
1751 $org_node = ['@type' => 'Organization'];
1752 $org_node['name'] = $schema_settings['org_name'];
1753 if (!empty($schema_settings['org_url'])) {
1754 $org_node['url'] = $schema_settings['org_url'];
1755 }
1756 if (!empty($schema_settings['org_logo'])) {
1757 $org_node['logo'] = [
1758 '@type' => 'ImageObject',
1759 'url' => $schema_settings['org_logo'],
1760 ];
1761 }
1762 if (!empty($schema_settings['org_contact_telephone']) || !empty($schema_settings['org_contact_type'])) {
1763 $org_node['contactPoint'] = ['@type' => 'ContactPoint'];
1764 if (!empty($schema_settings['org_contact_telephone'])) {
1765 $org_node['contactPoint']['telephone'] = $schema_settings['org_contact_telephone'];
1766 }
1767 if (!empty($schema_settings['org_contact_type'])) {
1768 $org_node['contactPoint']['contactType'] = $schema_settings['org_contact_type'];
1769 }
1770 }
1771 if (!empty($schema_settings['org_same_as'])) {
1772 $urls = array_filter(array_map('trim', explode("\n", $schema_settings['org_same_as'])));
1773 if (!empty($urls)) {
1774 $org_node['sameAs'] = $urls;
1775 }
1776 }
1777 $all_json_ld[] = $org_node;
1778 }
1779 }
1780
1781 // WebSite schema (front page only)
1782 if (is_front_page()) {
1783 if (!empty($schema_settings['website_name']) && $this->should_output_schema_type('WebSite')) {
1784 $ws_node = ['@type' => 'WebSite'];
1785 $ws_node['name'] = $schema_settings['website_name'];
1786 $ws_node['url'] = !empty($schema_settings['website_url']) ? $schema_settings['website_url'] : home_url('/');
1787 if (!empty($schema_settings['website_searchbox'])) {
1788 $base_url = rtrim($ws_node['url'], '/');
1789 $ws_node['potentialAction'] = [
1790 '@type' => 'SearchAction',
1791 'target' => [
1792 '@type' => 'EntryPoint',
1793 'urlTemplate' => $base_url . '/?s={search_term_string}',
1794 ],
1795 'query-input' => 'required name=search_term_string',
1796 ];
1797 }
1798 $all_json_ld[] = $ws_node;
1799 }
1800 }
1801
1802 // Person schema on author archive pages
1803 if (is_author()) {
1804 if ($this->should_output_schema_type('Person')) {
1805 $author = get_queried_object();
1806 if ($author && is_a($author, 'WP_User')) {
1807 $person_node = [
1808 '@type' => 'Person',
1809 'name' => $author->display_name,
1810 'url' => get_author_posts_url($author->ID),
1811 ];
1812 $user_desc = get_the_author_meta('description', $author->ID);
1813 if (!empty($user_desc)) {
1814 $person_node['description'] = $user_desc;
1815 }
1816 $all_json_ld[] = $person_node;
1817 }
1818 }
1819 }
1820
1821 // ---- Per-post schema (singular pages only) ----
1822 if (is_singular()) {
1823 global $post;
1824 if ($post && isset($post->ID)) {
1825 $schema_data = get_post_meta($post->ID, 'metasync_schema_markup', true);
1826
1827 $has_user_types = !empty($schema_data['enabled']) && !empty($schema_data['types']);
1828
1829 // Track which types the global schema already outputs
1830 $global_types_output = [];
1831 if (!empty($schema_settings['org_name'])) {
1832 $global_types_output[] = 'Organization';
1833 }
1834 if (is_front_page() && !empty($schema_settings['website_name'])) {
1835 $global_types_output[] = 'WebSite';
1836 }
1837
1838 // Generate JSON-LD for user-entered schema types.
1839 if ($has_user_types) {
1840 $validation_errors = get_post_meta($post->ID, '_metasync_schema_validation_errors', true);
1841 if (empty($validation_errors) || !is_array($validation_errors)) {
1842 foreach ($schema_data['types'] as $schema_type_data) {
1843 // Skip per-post types that the global schema already covers
1844 $type_key = $schema_type_data['type'];
1845 if (in_array($type_key, $global_types_output, true)) {
1846 continue;
1847 }
1848
1849 // Skip BreadcrumbList if already injected by the breadcrumbs module.
1850 if (
1851 $type_key === 'BreadcrumbList'
1852 && class_exists('Metasync_Breadcrumbs_Schema')
1853 && Metasync_Breadcrumbs_Schema::$breadcrumb_list_injected
1854 ) {
1855 continue;
1856 }
1857
1858 if ($this->should_output_schema_type($type_key)) {
1859 $json_ld = $this->generate_json_ld($post, $schema_type_data);
1860 if ($json_ld) {
1861 $all_json_ld[] = $json_ld;
1862 }
1863 }
1864 }
1865 }
1866 }
1867
1868 // OTTO-persisted JSON-LD: skip only while OTTO is live — avoid
1869 // duplicate; the persisted copy is output below when OTTO is off.
1870 //
1871 // While OTTO is enabled, schema is delivered live via `header_html_insertion`
1872 // and injected by Otto_html_class with a `data-otto="true"` marker, so merging
1873 // the DB-persisted copy into this @graph would produce duplicate JSON-LD on
1874 // every page. When OTTO is OFF there is no live injection and therefore
1875 // no duplicate risk, yet the schema is still saved in the DB — so we surface the
1876 // persisted copy as its own standalone tag after the @graph block.
1877 if (
1878 isset($schema_data['otto_jsonld']['data'])
1879 && is_string($schema_data['otto_jsonld']['data'])
1880 && $schema_data['otto_jsonld']['data'] !== ''
1881 && (!class_exists('Metasync_Otto_Config') || !Metasync_Otto_Config::is_otto_enabled())
1882 && !$this->active_seo_plugin_emits_schema()
1883 ) {
1884 $otto_persisted_jsonld = $schema_data['otto_jsonld']['data'];
1885 }
1886 }
1887 }
1888
1889 // Output all schemas in a single script tag with @graph pattern
1890 if (!empty($all_json_ld)) {
1891 $final_json_ld = [
1892 '@context' => 'https://schema.org',
1893 '@graph' => $all_json_ld,
1894 ];
1895
1896 $graph_json = metasync_safe_json_ld_encode($final_json_ld, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
1897 if (is_string($graph_json) && $graph_json !== '') {
1898 echo '<script type="application/ld+json" class="metasync-schema">' . "\n";
1899 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already context-escaped by metasync_safe_json_ld_encode (wp_json_encode + "</" breakout neutralisation); a json_decode/wp_json_encode round-trip would corrupt Unicode payloads.
1900 echo $graph_json;
1901 echo "\n" . '</script>' . "\n";
1902 }
1903 }
1904
1905 // OTTO is off: output the DB-persisted OTTO JSON-LD as its own standalone tag.
1906 // Echoed without a json_decode/encode round-trip to match how OTTO injects it live
1907 // and to avoid the Unicode-corruption noted in. The only transform applied is
1908 // escaping `</` to `<\/` so a literal `</script>` inside a string value cannot break
1909 // out of the tag (page corruption / XSS). `\/` is a valid JSON escape for `/`, so the
1910 // consumer reads identical content and the persisted bytes are otherwise preserved.
1911 // The data field is a full JSON-LD document or node, so it is emitted as a separate
1912 // script rather than merged into the @graph above. Stays null while OTTO is live.
1913 //
1914 // NOTE (per-page scope): the OTTO gate above uses Metasync_Otto_Config::is_otto_enabled(),
1915 // which is global (UUID configured) rather than per-request. On a site where OTTO is
1916 // enabled globally but did not inject on this specific page (e.g. excluded/uncrawled URL),
1917 // the persisted copy is intentionally still skipped — OTTO owns schema delivery while it
1918 // is enabled, and emitting here cannot reliably know whether OTTO injected this request
1919 // without risking the duplicate. Tracked as a known limitation.
1920 if (!empty($otto_persisted_jsonld)) {
1921 echo '<script type="application/ld+json" class="metasync-schema metasync-otto-schema" data-otto="true">' . "\n";
1922 // Escape `</` -> `<\/` to prevent `</script>` breakout; valid JSON, content unchanged.
1923 echo metasync_escape_json_ld_for_script($otto_persisted_jsonld); // phpcs:ignore WordPress.Security.EscapeOutput -- persisted JSON-LD string, only `</` escaped for safe embedding
1924 echo "\n" . '</script>' . "\n";
1925 }
1926 }
1927
1928 /**
1929 * (AC#3): Whether an active third-party SEO plugin will already emit the schema.
1930 *
1931 * When OTTO syncs, it cross-writes the structured data into the active SEO plugin
1932 * (Yoast / Rank Math / AIOSEO), which then renders it from its own storage. If such a
1933 * plugin is active, also emitting our persisted copy would duplicate the JSON-LD — so the
1934 * caller suppresses the standalone OTTO tag and lets the SEO plugin own the output.
1935 *
1936 * Returns false when the conflict handler is unavailable (no third-party integration in
1937 * play), preserving the OTTO-off / no-SEO-plugin path that this feature exists for.
1938 *
1939 * @return bool
1940 */
1941 protected function active_seo_plugin_emits_schema() {
1942 if (!class_exists('Metasync_Seo_Conflict_Handler')) {
1943 return false;
1944 }
1945 return Metasync_Seo_Conflict_Handler::get_instance()->has_active_seo_plugin();
1946 }
1947
1948 /**
1949 * Replace placeholders with actual dynamic values
1950 *
1951 * @param string $value Value that may contain placeholders
1952 * @param int $post_id Post ID
1953 * @return string Value with placeholders replaced
1954 */
1955 private function replace_placeholders($value, $post_id)
1956 {
1957 if (empty($value)) {
1958 return $value;
1959 }
1960
1961 $defaults = $this->get_default_schema_values($post_id);
1962
1963 // Replace placeholders with actual values
1964 $value = str_replace('{{post_title}}', $defaults['title'], $value);
1965 $value = str_replace('{{post_description}}', $defaults['description'], $value);
1966 $value = str_replace('{{featured_image}}', $defaults['image'], $value);
1967
1968 return $value;
1969 }
1970
1971 /**
1972 * Check if a value is a placeholder (should be treated as empty/default)
1973 *
1974 * @param string $value Value to check
1975 * @return bool True if value is a placeholder
1976 */
1977 private function is_placeholder($value)
1978 {
1979 $placeholders = ['{{post_title}}', '{{post_description}}', '{{featured_image}}'];
1980 return in_array(trim($value), $placeholders, true);
1981 }
1982
1983 /**
1984 * Get effective schema value (override if provided, otherwise default)
1985 * Replaces placeholders with actual dynamic values during JSON generation
1986 *
1987 * @param string $field_name Field name (title_override, description_override, image_override)
1988 * @param array $fields Schema fields array
1989 * @param int $post_id Post ID
1990 * @return string Effective value with placeholders replaced
1991 */
1992 private function get_effective_schema_value($field_name, $fields, $post_id)
1993 {
1994 // Get override value from database
1995 $override_key = $field_name;
1996 $override_value = isset($fields[$override_key]) ? trim($fields[$override_key]) : '';
1997
1998 // If override value is empty (blank field), return empty
1999 // This will trigger validation error - user must either fill it or use placeholder
2000 if (empty($override_value)) {
2001 return '';
2002 }
2003
2004 // If override value is provided (including placeholders), replace placeholders with actual data
2005 return $this->replace_placeholders($override_value, $post_id);
2006 }
2007
2008 /**
2009 * Generate JSON-LD based on schema type
2010 */
2011 private function generate_json_ld($post, $schema_type_data)
2012 {
2013 $type = $schema_type_data['type'];
2014 $fields = $schema_type_data['fields'];
2015
2016 // Build base JSON-LD without @context (will be added at root level)
2017 // Map type keys to proper Schema.org @type values
2018 $type_map = [
2019 'article' => 'Article',
2020 'FAQPage' => 'FAQPage',
2021 'product' => 'Product',
2022 'recipe' => 'Recipe',
2023 'Event' => 'Event',
2024 'JobPosting' => 'JobPosting',
2025 'Review' => 'Review',
2026 'Course' => 'Course',
2027 'Organization' => 'Organization',
2028 'Person' => 'Person',
2029 'WebSite' => 'WebSite',
2030 'NewsArticle' => 'NewsArticle',
2031 'LocalBusiness' => 'LocalBusiness',
2032 'HowTo' => 'HowTo',
2033 'VideoObject' => 'VideoObject',
2034 ];
2035 $json_ld = [
2036 '@type' => isset($type_map[$type]) ? $type_map[$type] : ucfirst($type)
2037 ];
2038
2039 // Types that manage their own fields (no override fields pattern)
2040 $self_managed_types = ['Event', 'JobPosting', 'Review', 'Course', 'Organization', 'Person', 'WebSite', 'LocalBusiness', 'VideoObject'];
2041
2042 if (!in_array($type, $self_managed_types)) {
2043 // Get effective values (override if provided, otherwise defaults)
2044 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post->ID);
2045 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post->ID);
2046 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post->ID);
2047
2048 // For articles/NewsArticle, don't add 'name' and 'url' as we'll use 'headline' and 'mainEntityOfPage' instead
2049 // For FAQ, we only need the schema type and mainEntity
2050 if ($type !== 'article' && $type !== 'FAQPage' && $type !== 'NewsArticle') {
2051 $json_ld['name'] = $effective_title;
2052 $json_ld['url'] = get_permalink($post->ID);
2053 $json_ld['description'] = $effective_description;
2054
2055 // Add images - use override if provided, otherwise get from featured image
2056 if (!empty($effective_image)) {
2057 $json_ld['image'] = $effective_image;
2058 } else {
2059 $images = $this->get_post_images($post->ID);
2060 if (!empty($images)) {
2061 $json_ld['image'] = count($images) === 1 ? $images[0] : $images;
2062 }
2063 }
2064 } elseif ($type === 'article' || $type === 'NewsArticle') {
2065 $json_ld['description'] = $effective_description;
2066
2067 // Add images - use override if provided, otherwise get from featured image
2068 if (!empty($effective_image)) {
2069 $json_ld['image'] = $effective_image;
2070 } else {
2071 $images = $this->get_post_images($post->ID);
2072 if (!empty($images)) {
2073 $json_ld['image'] = count($images) === 1 ? $images[0] : $images;
2074 }
2075 }
2076 }
2077 }
2078
2079 switch ($type) {
2080 case 'article':
2081 $json_ld = array_merge($json_ld, $this->generate_article_json_ld($fields, $post));
2082 break;
2083 case 'FAQPage':
2084 $json_ld = array_merge($json_ld, $this->generate_faq_json_ld($fields));
2085 break;
2086 case 'product':
2087 $json_ld = array_merge($json_ld, $this->generate_product_json_ld($fields));
2088 break;
2089 case 'recipe':
2090 $json_ld = array_merge($json_ld, $this->generate_recipe_json_ld($fields));
2091 break;
2092 case 'Event':
2093 $json_ld = array_merge($json_ld, $this->generate_event_json_ld($fields, $post));
2094 break;
2095 case 'JobPosting':
2096 $json_ld = array_merge($json_ld, $this->generate_jobposting_json_ld($fields, $post));
2097 break;
2098 case 'Review':
2099 $json_ld = array_merge($json_ld, $this->generate_review_json_ld($fields, $post));
2100 break;
2101 case 'Course':
2102 $json_ld = array_merge($json_ld, $this->generate_course_json_ld($fields, $post));
2103 break;
2104 case 'Organization':
2105 $json_ld = array_merge($json_ld, $this->generate_organization_json_ld($fields, $post));
2106 break;
2107 case 'Person':
2108 $json_ld = array_merge($json_ld, $this->generate_person_json_ld($fields, $post));
2109 break;
2110 case 'WebSite':
2111 $json_ld = array_merge($json_ld, $this->generate_website_json_ld($fields, $post));
2112 break;
2113 case 'NewsArticle':
2114 $json_ld = array_merge($json_ld, $this->generate_newsarticle_json_ld($fields, $post));
2115 break;
2116 case 'LocalBusiness':
2117 $local_business_data = $this->generate_local_business_json_ld($fields);
2118 if ($local_business_data === false) {
2119 return null;
2120 }
2121 $json_ld = array_merge($json_ld, $local_business_data);
2122 break;
2123 case 'HowTo':
2124 $json_ld = array_merge($json_ld, $this->generate_howto_json_ld($fields));
2125 break;
2126 case 'VideoObject':
2127 $json_ld = array_merge($json_ld, $this->generate_video_object_json_ld($fields));
2128 break;
2129 }
2130
2131 return $json_ld;
2132 }
2133
2134 /**
2135 * Generate Article JSON-LD
2136 */
2137 private function generate_article_json_ld($fields, $post)
2138 {
2139 $article_data = [];
2140
2141 // Add headline (required by Google) - use override if provided
2142 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post->ID);
2143 $article_data['headline'] = $effective_title;
2144
2145 // Add mainEntityOfPage instead of url (Google's standard for articles)
2146 $article_data['mainEntityOfPage'] = [
2147 '@type' => 'WebPage',
2148 '@id' => get_permalink($post->ID)
2149 ];
2150
2151 // Add datePublished (required by Google)
2152 $article_data['datePublished'] = get_the_date('c', $post->ID);
2153
2154 // Add dateModified (required by Google)
2155 $article_data['dateModified'] = get_the_modified_date('c', $post->ID);
2156
2157 // Add author information
2158 if (!empty($fields['organization_name'])) {
2159 // Organization as author
2160 $article_data['author'] = [
2161 '@type' => 'Organization',
2162 'name' => $fields['organization_name']
2163 ];
2164
2165 // Add organization logo if provided
2166 if (!empty($fields['organization_logo'])) {
2167 $article_data['author']['logo'] = [
2168 '@type' => 'ImageObject',
2169 'url' => $fields['organization_logo']
2170 ];
2171 }
2172 } else {
2173 // Fallback to WordPress post author
2174 $author_id = $post->post_author;
2175 $author_name = get_the_author_meta('display_name', $author_id);
2176 $author_url = get_author_posts_url($author_id);
2177
2178 $article_data['author'] = [
2179 '@type' => 'Person',
2180 'name' => $author_name,
2181 'url' => $author_url
2182 ];
2183 }
2184
2185 // Add publisher information (required by Google)
2186 if (!empty($fields['organization_name'])) {
2187 $article_data['publisher'] = [
2188 '@type' => 'Organization',
2189 'name' => $fields['organization_name']
2190 ];
2191
2192 // Add organization logo if provided (required for publisher)
2193 if (!empty($fields['organization_logo'])) {
2194 $article_data['publisher']['logo'] = [
2195 '@type' => 'ImageObject',
2196 'url' => $fields['organization_logo']
2197 ];
2198 }
2199 }
2200
2201 return $article_data;
2202 }
2203
2204 /**
2205 * Generate FAQ JSON-LD
2206 */
2207 private function generate_faq_json_ld($fields)
2208 {
2209 $faq_data = [];
2210
2211 // Add FAQ mainEntity
2212 if (!empty($fields['faq_items'])) {
2213 $faq_data['mainEntity'] = [];
2214
2215 foreach ($fields['faq_items'] as $item) {
2216 $faq_data['mainEntity'][] = [
2217 '@type' => 'Question',
2218 'name' => $item['question'],
2219 'acceptedAnswer' => [
2220 '@type' => 'Answer',
2221 'text' => $item['answer']
2222 ]
2223 ];
2224 }
2225 }
2226
2227 return $faq_data;
2228 }
2229
2230 /**
2231 * Generate Product JSON-LD
2232 */
2233 private function generate_product_json_ld($fields)
2234 {
2235 $metasync_options = get_option('metasync_options', []);
2236 $override_wc = !empty($metasync_options['schema']['override_woocommerce_schema']);
2237 $wc_active = class_exists('WooCommerce');
2238
2239 // When WooCommerce is active and override is OFF, suppress MetaSync Product
2240 // schema entirely — let WooCommerce handle its own Product structured data.
2241 if ($wc_active && !$override_wc) {
2242 return [];
2243 }
2244
2245 $product_data = [];
2246
2247 if (!empty($fields['sku'])) {
2248 $product_data['sku'] = $fields['sku'];
2249 }
2250
2251 if (!empty($fields['brand'])) {
2252 $product_data['brand'] = [
2253 '@type' => 'Brand',
2254 'name' => $fields['brand']
2255 ];
2256 }
2257
2258 if (!empty($fields['price'])) {
2259 $product_data['offers'] = [
2260 '@type' => 'Offer',
2261 'price' => $fields['price'],
2262 'priceCurrency' => $fields['currency'] ?? 'USD',
2263 'availability' => 'https://schema.org/' . ($fields['availability'] ?? 'InStock'),
2264 'itemCondition' => 'https://schema.org/' . ($fields['condition'] ?? 'NewCondition')
2265 ];
2266 }
2267
2268 // When WooCommerce is active AND override is ON, enrich with WC product data
2269 if ($wc_active && $override_wc && function_exists('wc_get_product')) {
2270 global $post;
2271 $wc_post_id = isset($post->ID) ? $post->ID : 0;
2272 $product = wc_get_product($wc_post_id);
2273 if ($product && is_a($product, 'WC_Product')) {
2274 // Auto-fill price from WC (overwrites manual fields with live data)
2275 $wc_price = $product->get_price();
2276 if (!empty($wc_price)) {
2277 if (!isset($product_data['offers'])) {
2278 $product_data['offers'] = ['@type' => 'Offer'];
2279 }
2280 $product_data['offers']['price'] = $wc_price;
2281 $product_data['offers']['priceCurrency'] = get_woocommerce_currency();
2282 $product_data['offers']['availability'] = 'https://schema.org/' . ($product->is_in_stock() ? 'InStock' : 'OutOfStock');
2283 }
2284
2285 // Auto-fill SKU
2286 $wc_sku = $product->get_sku();
2287 if (!empty($wc_sku)) {
2288 $product_data['sku'] = $wc_sku;
2289 }
2290
2291 // AggregateRating from WooCommerce reviews
2292 $review_count = $product->get_review_count();
2293 if ($review_count > 0) {
2294 $product_data['aggregateRating'] = [
2295 '@type' => 'AggregateRating',
2296 'ratingValue' => $product->get_average_rating(),
2297 'reviewCount' => $review_count,
2298 ];
2299 }
2300 }
2301 }
2302
2303 return $product_data;
2304 }
2305
2306 /**
2307 * Generate Recipe JSON-LD
2308 */
2309 private function generate_recipe_json_ld($fields)
2310 {
2311 $recipe_data = [];
2312
2313 if (!empty($fields['yield'])) {
2314 $recipe_data['recipeYield'] = $fields['yield'];
2315 }
2316
2317 if (!empty($fields['ingredients'])) {
2318 $recipe_data['recipeIngredient'] = $fields['ingredients'];
2319 }
2320
2321 if (!empty($fields['instructions'])) {
2322 $recipe_data['recipeInstructions'] = [];
2323 foreach ($fields['instructions'] as $instruction) {
2324 $recipe_data['recipeInstructions'][] = [
2325 '@type' => 'HowToStep',
2326 'text' => $instruction
2327 ];
2328 }
2329 }
2330
2331 if (!empty($fields['prep_time'])) {
2332 $recipe_data['prepTime'] = 'PT' . $fields['prep_time'] . 'M';
2333 }
2334
2335 if (!empty($fields['cook_time'])) {
2336 $recipe_data['cookTime'] = 'PT' . $fields['cook_time'] . 'M';
2337 }
2338
2339 if (!empty($fields['total_time'])) {
2340 $recipe_data['totalTime'] = 'PT' . $fields['total_time'] . 'M';
2341 }
2342
2343 if (!empty($fields['calories'])) {
2344 $recipe_data['nutrition'] = [
2345 '@type' => 'NutritionInformation',
2346 'calories' => $fields['calories'] . ' calories'
2347 ];
2348 }
2349
2350 return $recipe_data;
2351 }
2352
2353 // ======================================================================
2354 // New Schema Type: Event
2355 // ======================================================================
2356
2357 /**
2358 * Render Event schema fields
2359 */
2360 private function render_event_fields($fields, $index = 0)
2361 {
2362 $name = isset($fields['name']) ? $fields['name'] : '';
2363 $startDate = isset($fields['startDate']) ? $fields['startDate'] : '';
2364 $endDate = isset($fields['endDate']) ? $fields['endDate'] : '';
2365 $eventStatus = isset($fields['eventStatus']) ? $fields['eventStatus'] : 'EventScheduled';
2366 $eventAttendanceMode = isset($fields['eventAttendanceMode']) ? $fields['eventAttendanceMode'] : 'OfflineEventAttendanceMode';
2367 $location_name = isset($fields['location_name']) ? $fields['location_name'] : '';
2368 $location_address = isset($fields['location_address']) ? $fields['location_address'] : '';
2369 $organizer_name = isset($fields['organizer_name']) ? $fields['organizer_name'] : '';
2370 $organizer_url = isset($fields['organizer_url']) ? $fields['organizer_url'] : '';
2371 $offer_price = isset($fields['offer_price']) ? $fields['offer_price'] : '';
2372 $offer_priceCurrency = isset($fields['offer_priceCurrency']) ? $fields['offer_priceCurrency'] : 'USD';
2373 $offer_availability = isset($fields['offer_availability']) ? $fields['offer_availability'] : 'InStock';
2374 $offer_url = isset($fields['offer_url']) ? $fields['offer_url'] : '';
2375 $image = isset($fields['image']) ? $fields['image'] : '';
2376 $description = isset($fields['description']) ? $fields['description'] : '';
2377
2378 ?>
2379 <div class="schema-field-group">
2380 <h4>Event Information</h4>
2381 <p class="description" style="margin-top: 0;">Fields marked with * are required.</p>
2382
2383 <div class="schema-field">
2384 <label for="event_name_<?php echo esc_attr((string) $index); ?>">Event Name: <span style="color: #dc3232;">*</span></label>
2385 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][name]" id="event_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($name); ?>" placeholder="e.g., Annual Tech Conference">
2386 </div>
2387
2388 <div class="schema-field">
2389 <label for="event_startDate_<?php echo esc_attr((string) $index); ?>">Start Date: <span style="color: #dc3232;">*</span></label>
2390 <input type="datetime-local" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][startDate]" id="event_startDate_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($startDate); ?>">
2391 </div>
2392
2393 <div class="schema-field">
2394 <label for="event_endDate_<?php echo esc_attr((string) $index); ?>">End Date:</label>
2395 <input type="datetime-local" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][endDate]" id="event_endDate_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($endDate); ?>">
2396 </div>
2397
2398 <div class="schema-field">
2399 <label for="event_status_<?php echo esc_attr((string) $index); ?>">Event Status:</label>
2400 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][eventStatus]" id="event_status_<?php echo esc_attr((string) $index); ?>">
2401 <option value="EventScheduled" <?php selected($eventStatus, 'EventScheduled'); ?>>Scheduled</option>
2402 <option value="EventCancelled" <?php selected($eventStatus, 'EventCancelled'); ?>>Cancelled</option>
2403 <option value="EventPostponed" <?php selected($eventStatus, 'EventPostponed'); ?>>Postponed</option>
2404 <option value="EventRescheduled" <?php selected($eventStatus, 'EventRescheduled'); ?>>Rescheduled</option>
2405 </select>
2406 </div>
2407
2408 <div class="schema-field">
2409 <label for="event_attendance_<?php echo esc_attr((string) $index); ?>">Attendance Mode:</label>
2410 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][eventAttendanceMode]" id="event_attendance_<?php echo esc_attr((string) $index); ?>">
2411 <option value="OfflineEventAttendanceMode" <?php selected($eventAttendanceMode, 'OfflineEventAttendanceMode'); ?>>In-Person</option>
2412 <option value="OnlineEventAttendanceMode" <?php selected($eventAttendanceMode, 'OnlineEventAttendanceMode'); ?>>Online</option>
2413 <option value="MixedEventAttendanceMode" <?php selected($eventAttendanceMode, 'MixedEventAttendanceMode'); ?>>Mixed</option>
2414 </select>
2415 </div>
2416
2417 <div class="schema-field">
2418 <label for="event_location_name_<?php echo esc_attr((string) $index); ?>">Location Name:</label>
2419 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][location_name]" id="event_location_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($location_name); ?>" placeholder="e.g., Convention Center">
2420 </div>
2421
2422 <div class="schema-field">
2423 <label for="event_location_address_<?php echo esc_attr((string) $index); ?>">Location Address:</label>
2424 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][location_address]" id="event_location_address_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($location_address); ?>" placeholder="e.g., 123 Main St, City, State">
2425 </div>
2426
2427 <div class="schema-field">
2428 <label for="event_organizer_name_<?php echo esc_attr((string) $index); ?>">Organizer Name:</label>
2429 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][organizer_name]" id="event_organizer_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($organizer_name); ?>" placeholder="Organization name">
2430 </div>
2431
2432 <div class="schema-field">
2433 <label for="event_organizer_url_<?php echo esc_attr((string) $index); ?>">Organizer URL:</label>
2434 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][organizer_url]" id="event_organizer_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($organizer_url); ?>" placeholder="https://example.com">
2435 </div>
2436
2437 <div class="schema-field">
2438 <label for="event_offer_price_<?php echo esc_attr((string) $index); ?>">Ticket Price:</label>
2439 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][offer_price]" id="event_offer_price_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($offer_price); ?>" placeholder="0.00">
2440 </div>
2441
2442 <div class="schema-field">
2443 <label for="event_offer_currency_<?php echo esc_attr((string) $index); ?>">Price Currency:</label>
2444 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][offer_priceCurrency]" id="event_offer_currency_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($offer_priceCurrency); ?>" placeholder="USD">
2445 </div>
2446
2447 <div class="schema-field">
2448 <label for="event_offer_availability_<?php echo esc_attr((string) $index); ?>">Availability:</label>
2449 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][offer_availability]" id="event_offer_availability_<?php echo esc_attr((string) $index); ?>">
2450 <option value="InStock" <?php selected($offer_availability, 'InStock'); ?>>In Stock</option>
2451 <option value="SoldOut" <?php selected($offer_availability, 'SoldOut'); ?>>Sold Out</option>
2452 <option value="PreOrder" <?php selected($offer_availability, 'PreOrder'); ?>>Pre-Order</option>
2453 <option value="LimitedAvailability" <?php selected($offer_availability, 'LimitedAvailability'); ?>>Limited Availability</option>
2454 </select>
2455 </div>
2456
2457 <div class="schema-field">
2458 <label for="event_offer_url_<?php echo esc_attr((string) $index); ?>">Ticket URL:</label>
2459 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][offer_url]" id="event_offer_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($offer_url); ?>" placeholder="https://example.com/tickets">
2460 </div>
2461
2462 <div class="schema-field">
2463 <label for="event_image_<?php echo esc_attr((string) $index); ?>">Image URL:</label>
2464 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][image]" id="event_image_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($image); ?>" placeholder="https://example.com/event-image.jpg">
2465 </div>
2466
2467 <div class="schema-field">
2468 <label for="event_description_<?php echo esc_attr((string) $index); ?>">Description:</label>
2469 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][description]" id="event_description_<?php echo esc_attr((string) $index); ?>" rows="4" style="width: 100%;" placeholder="Event description"><?php echo esc_textarea($description); ?></textarea>
2470 </div>
2471 </div>
2472 <?php
2473 }
2474
2475 /**
2476 * Validate Event schema
2477 */
2478 private function validate_event_schema($post_id, $fields)
2479 {
2480 $errors = [];
2481
2482 if (empty($fields['name']) || trim($fields['name']) === '') {
2483 $errors[] = [
2484 'field' => 'name',
2485 'message' => 'Event schema requires a <strong>Name</strong>.'
2486 ];
2487 }
2488
2489 if (empty($fields['startDate']) || trim($fields['startDate']) === '') {
2490 $errors[] = [
2491 'field' => 'startDate',
2492 'message' => 'Event schema requires a <strong>Start Date</strong>.'
2493 ];
2494 }
2495
2496 return $errors;
2497 }
2498
2499 /**
2500 * Format an event date with the site's timezone offset for ISO 8601
2501 */
2502 private function format_event_date($date_string)
2503 {
2504 if (empty($date_string)) {
2505 return $date_string;
2506 }
2507
2508 // If it already has timezone info, return as-is
2509 if (preg_match('/[+-]\d{2}:\d{2}$|Z$/', $date_string)) {
2510 return $date_string;
2511 }
2512
2513 // Append WordPress site timezone offset
2514 $timezone = wp_timezone();
2515 try {
2516 $dt = new \DateTime($date_string, $timezone);
2517 return $dt->format('c'); // ISO 8601 with timezone
2518 } catch (\Exception $e) {
2519 return $date_string;
2520 }
2521 }
2522
2523 /**
2524 * Generate Event JSON-LD
2525 */
2526 private function generate_event_json_ld($fields, $post)
2527 {
2528 $data = [];
2529
2530 if (!empty($fields['name'])) {
2531 $data['name'] = $fields['name'];
2532 }
2533 if (!empty($fields['startDate'])) {
2534 $data['startDate'] = $this->format_event_date($fields['startDate']);
2535 }
2536 if (!empty($fields['endDate'])) {
2537 $data['endDate'] = $this->format_event_date($fields['endDate']);
2538 }
2539 if (!empty($fields['eventStatus'])) {
2540 $data['eventStatus'] = 'https://schema.org/' . $fields['eventStatus'];
2541 }
2542 if (!empty($fields['eventAttendanceMode'])) {
2543 $data['eventAttendanceMode'] = 'https://schema.org/' . $fields['eventAttendanceMode'];
2544 }
2545 if (!empty($fields['description'])) {
2546 $data['description'] = $fields['description'];
2547 }
2548 if (!empty($fields['image'])) {
2549 $data['image'] = $fields['image'];
2550 }
2551
2552 if (!empty($fields['location_name']) || !empty($fields['location_address'])) {
2553 $data['location'] = [
2554 '@type' => 'Place',
2555 ];
2556 if (!empty($fields['location_name'])) {
2557 $data['location']['name'] = $fields['location_name'];
2558 }
2559 if (!empty($fields['location_address'])) {
2560 $data['location']['address'] = [
2561 '@type' => 'PostalAddress',
2562 'streetAddress' => $fields['location_address'],
2563 ];
2564 }
2565 }
2566
2567 if (!empty($fields['organizer_name'])) {
2568 $data['organizer'] = [
2569 '@type' => 'Organization',
2570 'name' => $fields['organizer_name'],
2571 ];
2572 if (!empty($fields['organizer_url'])) {
2573 $data['organizer']['url'] = $fields['organizer_url'];
2574 }
2575 }
2576
2577 if (!empty($fields['offer_price'])) {
2578 $data['offers'] = [
2579 '@type' => 'Offer',
2580 'price' => $fields['offer_price'],
2581 'priceCurrency' => !empty($fields['offer_priceCurrency']) ? $fields['offer_priceCurrency'] : 'USD',
2582 'availability' => 'https://schema.org/' . (!empty($fields['offer_availability']) ? $fields['offer_availability'] : 'InStock'),
2583 ];
2584 if (!empty($fields['offer_url'])) {
2585 $data['offers']['url'] = $fields['offer_url'];
2586 }
2587 }
2588
2589 return $data;
2590 }
2591
2592 // ======================================================================
2593 // New Schema Type: JobPosting
2594 // ======================================================================
2595
2596 /**
2597 * Render JobPosting schema fields
2598 */
2599 private function render_jobposting_fields($fields, $index = 0)
2600 {
2601 $title = isset($fields['title']) ? $fields['title'] : '';
2602 $description = isset($fields['description']) ? $fields['description'] : '';
2603 $datePosted = isset($fields['datePosted']) ? $fields['datePosted'] : '';
2604 $validThrough = isset($fields['validThrough']) ? $fields['validThrough'] : '';
2605 $employmentType = isset($fields['employmentType']) ? $fields['employmentType'] : 'FULL_TIME';
2606 $hiringOrganization_name = isset($fields['hiringOrganization_name']) ? $fields['hiringOrganization_name'] : '';
2607 $hiringOrganization_sameAs = isset($fields['hiringOrganization_sameAs']) ? $fields['hiringOrganization_sameAs'] : '';
2608 $hiringOrganization_logo = isset($fields['hiringOrganization_logo']) ? $fields['hiringOrganization_logo'] : '';
2609 $jobLocation_address = isset($fields['jobLocation_address']) ? $fields['jobLocation_address'] : '';
2610 $baseSalary_currency = isset($fields['baseSalary_currency']) ? $fields['baseSalary_currency'] : '';
2611 $baseSalary_value = isset($fields['baseSalary_value']) ? $fields['baseSalary_value'] : '';
2612 $baseSalary_unitText = isset($fields['baseSalary_unitText']) ? $fields['baseSalary_unitText'] : 'YEAR';
2613
2614 ?>
2615 <div class="schema-field-group">
2616 <h4>Job Posting Information</h4>
2617 <p class="description" style="margin-top: 0;">Fields marked with * are required.</p>
2618
2619 <div class="schema-field">
2620 <label for="job_title_<?php echo esc_attr((string) $index); ?>">Job Title: <span style="color: #dc3232;">*</span></label>
2621 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][title]" id="job_title_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($title); ?>" placeholder="e.g., Software Engineer">
2622 </div>
2623
2624 <div class="schema-field">
2625 <label for="job_description_<?php echo esc_attr((string) $index); ?>">Description: <span style="color: #dc3232;">*</span></label>
2626 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][description]" id="job_description_<?php echo esc_attr((string) $index); ?>" rows="4" style="width: 100%;" placeholder="Job description"><?php echo esc_textarea($description); ?></textarea>
2627 </div>
2628
2629 <div class="schema-field">
2630 <label for="job_datePosted_<?php echo esc_attr((string) $index); ?>">Date Posted: <span style="color: #dc3232;">*</span></label>
2631 <input type="date" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][datePosted]" id="job_datePosted_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($datePosted); ?>">
2632 </div>
2633
2634 <div class="schema-field">
2635 <label for="job_validThrough_<?php echo esc_attr((string) $index); ?>">Valid Through:</label>
2636 <input type="date" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][validThrough]" id="job_validThrough_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($validThrough); ?>">
2637 </div>
2638
2639 <div class="schema-field">
2640 <label for="job_employmentType_<?php echo esc_attr((string) $index); ?>">Employment Type:</label>
2641 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][employmentType]" id="job_employmentType_<?php echo esc_attr((string) $index); ?>">
2642 <option value="FULL_TIME" <?php selected($employmentType, 'FULL_TIME'); ?>>Full Time</option>
2643 <option value="PART_TIME" <?php selected($employmentType, 'PART_TIME'); ?>>Part Time</option>
2644 <option value="CONTRACTOR" <?php selected($employmentType, 'CONTRACTOR'); ?>>Contractor</option>
2645 <option value="TEMPORARY" <?php selected($employmentType, 'TEMPORARY'); ?>>Temporary</option>
2646 <option value="INTERN" <?php selected($employmentType, 'INTERN'); ?>>Intern</option>
2647 <option value="VOLUNTEER" <?php selected($employmentType, 'VOLUNTEER'); ?>>Volunteer</option>
2648 <option value="PER_DIEM" <?php selected($employmentType, 'PER_DIEM'); ?>>Per Diem</option>
2649 <option value="OTHER" <?php selected($employmentType, 'OTHER'); ?>>Other</option>
2650 </select>
2651 </div>
2652
2653 <div class="schema-field">
2654 <label for="job_org_name_<?php echo esc_attr((string) $index); ?>">Hiring Organization: <span style="color: #dc3232;">*</span></label>
2655 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][hiringOrganization_name]" id="job_org_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($hiringOrganization_name); ?>" placeholder="Company name">
2656 </div>
2657
2658 <div class="schema-field">
2659 <label for="job_org_url_<?php echo esc_attr((string) $index); ?>">Organization URL:</label>
2660 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][hiringOrganization_sameAs]" id="job_org_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($hiringOrganization_sameAs); ?>" placeholder="https://example.com">
2661 </div>
2662
2663 <div class="schema-field">
2664 <label for="job_org_logo_<?php echo esc_attr((string) $index); ?>">Organization Logo URL:</label>
2665 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][hiringOrganization_logo]" id="job_org_logo_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($hiringOrganization_logo); ?>" placeholder="https://example.com/logo.png">
2666 </div>
2667
2668 <div class="schema-field">
2669 <label for="job_location_<?php echo esc_attr((string) $index); ?>">Job Location: <span style="color: #dc3232;">*</span></label>
2670 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][jobLocation_address]" id="job_location_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($jobLocation_address); ?>" placeholder="e.g., 123 Main St, City, State">
2671 </div>
2672
2673 <div class="schema-field">
2674 <label for="job_salary_currency_<?php echo esc_attr((string) $index); ?>">Salary Currency:</label>
2675 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][baseSalary_currency]" id="job_salary_currency_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($baseSalary_currency); ?>" placeholder="USD">
2676 </div>
2677
2678 <div class="schema-field">
2679 <label for="job_salary_value_<?php echo esc_attr((string) $index); ?>">Salary Value:</label>
2680 <input type="number" step="0.01" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][baseSalary_value]" id="job_salary_value_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($baseSalary_value); ?>" placeholder="0.00">
2681 </div>
2682
2683 <div class="schema-field">
2684 <label for="job_salary_unit_<?php echo esc_attr((string) $index); ?>">Salary Unit:</label>
2685 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][baseSalary_unitText]" id="job_salary_unit_<?php echo esc_attr((string) $index); ?>">
2686 <option value="HOUR" <?php selected($baseSalary_unitText, 'HOUR'); ?>>Per Hour</option>
2687 <option value="DAY" <?php selected($baseSalary_unitText, 'DAY'); ?>>Per Day</option>
2688 <option value="WEEK" <?php selected($baseSalary_unitText, 'WEEK'); ?>>Per Week</option>
2689 <option value="MONTH" <?php selected($baseSalary_unitText, 'MONTH'); ?>>Per Month</option>
2690 <option value="YEAR" <?php selected($baseSalary_unitText, 'YEAR'); ?>>Per Year</option>
2691 </select>
2692 </div>
2693 </div>
2694 <?php
2695 }
2696
2697 /**
2698 * Validate JobPosting schema
2699 */
2700 private function validate_jobposting_schema($post_id, $fields)
2701 {
2702 $errors = [];
2703
2704 if (empty($fields['title']) || trim($fields['title']) === '') {
2705 $errors[] = ['field' => 'title', 'message' => 'Job Posting schema requires a <strong>Title</strong>.'];
2706 }
2707 if (empty($fields['description']) || trim($fields['description']) === '') {
2708 $errors[] = ['field' => 'description', 'message' => 'Job Posting schema requires a <strong>Description</strong>.'];
2709 }
2710 if (empty($fields['datePosted']) || trim($fields['datePosted']) === '') {
2711 $errors[] = ['field' => 'datePosted', 'message' => 'Job Posting schema requires a <strong>Date Posted</strong>.'];
2712 }
2713 if (empty($fields['hiringOrganization_name']) || trim($fields['hiringOrganization_name']) === '') {
2714 $errors[] = ['field' => 'hiringOrganization_name', 'message' => 'Job Posting schema requires a <strong>Hiring Organization</strong>.'];
2715 }
2716 if (empty($fields['jobLocation_address']) || trim($fields['jobLocation_address']) === '') {
2717 $errors[] = ['field' => 'jobLocation_address', 'message' => 'Job Posting schema requires a <strong>Job Location</strong>.'];
2718 }
2719
2720 return $errors;
2721 }
2722
2723 /**
2724 * Generate JobPosting JSON-LD
2725 */
2726 private function generate_jobposting_json_ld($fields, $post)
2727 {
2728 $data = [];
2729
2730 if (!empty($fields['title'])) {
2731 $data['title'] = $fields['title'];
2732 }
2733 if (!empty($fields['description'])) {
2734 $data['description'] = $fields['description'];
2735 }
2736 if (!empty($fields['datePosted'])) {
2737 $data['datePosted'] = $fields['datePosted'];
2738 }
2739 if (!empty($fields['validThrough'])) {
2740 $data['validThrough'] = $fields['validThrough'];
2741 }
2742 if (!empty($fields['employmentType'])) {
2743 $data['employmentType'] = $fields['employmentType'];
2744 }
2745
2746 if (!empty($fields['hiringOrganization_name'])) {
2747 $data['hiringOrganization'] = [
2748 '@type' => 'Organization',
2749 'name' => $fields['hiringOrganization_name'],
2750 ];
2751 if (!empty($fields['hiringOrganization_sameAs'])) {
2752 $data['hiringOrganization']['sameAs'] = $fields['hiringOrganization_sameAs'];
2753 }
2754 if (!empty($fields['hiringOrganization_logo'])) {
2755 $data['hiringOrganization']['logo'] = $fields['hiringOrganization_logo'];
2756 }
2757 }
2758
2759 if (!empty($fields['jobLocation_address'])) {
2760 $data['jobLocation'] = [
2761 '@type' => 'Place',
2762 'address' => [
2763 '@type' => 'PostalAddress',
2764 'streetAddress' => $fields['jobLocation_address'],
2765 ],
2766 ];
2767 }
2768
2769 if (!empty($fields['baseSalary_value']) && !empty($fields['baseSalary_currency'])) {
2770 $data['baseSalary'] = [
2771 '@type' => 'MonetaryAmount',
2772 'currency' => $fields['baseSalary_currency'],
2773 'value' => [
2774 '@type' => 'QuantitativeValue',
2775 'value' => $fields['baseSalary_value'],
2776 'unitText' => !empty($fields['baseSalary_unitText']) ? $fields['baseSalary_unitText'] : 'YEAR',
2777 ],
2778 ];
2779 }
2780
2781 return $data;
2782 }
2783
2784 // ======================================================================
2785 // New Schema Type: Review
2786 // ======================================================================
2787
2788 /**
2789 * Render Review schema fields
2790 */
2791 private function render_review_fields($fields, $index = 0)
2792 {
2793 $itemReviewed_name = isset($fields['itemReviewed_name']) ? $fields['itemReviewed_name'] : '';
2794 $reviewRating_ratingValue = isset($fields['reviewRating_ratingValue']) ? $fields['reviewRating_ratingValue'] : '';
2795 $reviewRating_bestRating = isset($fields['reviewRating_bestRating']) ? $fields['reviewRating_bestRating'] : '5';
2796 $author_name = isset($fields['author_name']) ? $fields['author_name'] : '';
2797 $reviewBody = isset($fields['reviewBody']) ? $fields['reviewBody'] : '';
2798
2799 ?>
2800 <div class="schema-field-group">
2801 <h4>Review Information</h4>
2802 <p class="description" style="margin-top: 0;">Fields marked with * are required.</p>
2803
2804 <div class="schema-field">
2805 <label for="review_item_<?php echo esc_attr((string) $index); ?>">Item Reviewed: <span style="color: #dc3232;">*</span></label>
2806 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][itemReviewed_name]" id="review_item_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($itemReviewed_name); ?>" placeholder="Name of the item being reviewed">
2807 </div>
2808
2809 <div class="schema-field">
2810 <label for="review_rating_<?php echo esc_attr((string) $index); ?>">Rating Value: <span style="color: #dc3232;">*</span></label>
2811 <input type="number" step="0.1" min="1" max="5" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][reviewRating_ratingValue]" id="review_rating_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($reviewRating_ratingValue); ?>" placeholder="1-5">
2812 </div>
2813
2814 <div class="schema-field">
2815 <label for="review_best_rating_<?php echo esc_attr((string) $index); ?>">Best Rating:</label>
2816 <input type="number" step="1" min="1" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][reviewRating_bestRating]" id="review_best_rating_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($reviewRating_bestRating); ?>" placeholder="5">
2817 </div>
2818
2819 <div class="schema-field">
2820 <label for="review_author_<?php echo esc_attr((string) $index); ?>">Author Name: <span style="color: #dc3232;">*</span></label>
2821 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][author_name]" id="review_author_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($author_name); ?>" placeholder="Reviewer name">
2822 </div>
2823
2824 <div class="schema-field">
2825 <label for="review_body_<?php echo esc_attr((string) $index); ?>">Review Body:</label>
2826 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][reviewBody]" id="review_body_<?php echo esc_attr((string) $index); ?>" rows="4" style="width: 100%;" placeholder="The review text"><?php echo esc_textarea($reviewBody); ?></textarea>
2827 </div>
2828 </div>
2829 <?php
2830 }
2831
2832 /**
2833 * Validate Review schema
2834 */
2835 private function validate_review_schema($post_id, $fields)
2836 {
2837 $errors = [];
2838
2839 if (empty($fields['itemReviewed_name']) || trim($fields['itemReviewed_name']) === '') {
2840 $errors[] = ['field' => 'itemReviewed_name', 'message' => 'Review schema requires an <strong>Item Reviewed</strong> name.'];
2841 }
2842 if (empty($fields['reviewRating_ratingValue']) || floatval($fields['reviewRating_ratingValue']) <= 0) {
2843 $errors[] = ['field' => 'reviewRating_ratingValue', 'message' => 'Review schema requires a <strong>Rating Value</strong>.'];
2844 }
2845 if (empty($fields['author_name']) || trim($fields['author_name']) === '') {
2846 $errors[] = ['field' => 'author_name', 'message' => 'Review schema requires an <strong>Author Name</strong>.'];
2847 }
2848
2849 return $errors;
2850 }
2851
2852 /**
2853 * Generate Review JSON-LD
2854 */
2855 private function generate_review_json_ld($fields, $post)
2856 {
2857 $data = [];
2858
2859 if (!empty($fields['itemReviewed_name'])) {
2860 $data['itemReviewed'] = [
2861 '@type' => 'Thing',
2862 'name' => $fields['itemReviewed_name'],
2863 ];
2864 }
2865
2866 if (!empty($fields['reviewRating_ratingValue'])) {
2867 $data['reviewRating'] = [
2868 '@type' => 'Rating',
2869 'ratingValue' => $fields['reviewRating_ratingValue'],
2870 'bestRating' => !empty($fields['reviewRating_bestRating']) ? $fields['reviewRating_bestRating'] : '5',
2871 ];
2872 }
2873
2874 if (!empty($fields['author_name'])) {
2875 $data['author'] = [
2876 '@type' => 'Person',
2877 'name' => $fields['author_name'],
2878 ];
2879 }
2880
2881 if (!empty($fields['reviewBody'])) {
2882 $data['reviewBody'] = $fields['reviewBody'];
2883 }
2884
2885 return $data;
2886 }
2887
2888 // ======================================================================
2889 // New Schema Type: Course
2890 // ======================================================================
2891
2892 /**
2893 * Render Course schema fields
2894 */
2895 private function render_course_fields($fields, $index = 0)
2896 {
2897 $name = isset($fields['name']) ? $fields['name'] : '';
2898 $description = isset($fields['description']) ? $fields['description'] : '';
2899 $provider_name = isset($fields['provider_name']) ? $fields['provider_name'] : '';
2900 $provider_sameAs = isset($fields['provider_sameAs']) ? $fields['provider_sameAs'] : '';
2901 $courseInstance_courseMode = isset($fields['courseInstance_courseMode']) ? $fields['courseInstance_courseMode'] : 'online';
2902 $courseInstance_instructor_name = isset($fields['courseInstance_instructor_name']) ? $fields['courseInstance_instructor_name'] : '';
2903
2904 ?>
2905 <div class="schema-field-group">
2906 <h4>Course Information</h4>
2907 <p class="description" style="margin-top: 0;">Fields marked with * are required.</p>
2908
2909 <div class="schema-field">
2910 <label for="course_name_<?php echo esc_attr((string) $index); ?>">Course Name: <span style="color: #dc3232;">*</span></label>
2911 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][name]" id="course_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($name); ?>" placeholder="e.g., Introduction to Web Development">
2912 </div>
2913
2914 <div class="schema-field">
2915 <label for="course_description_<?php echo esc_attr((string) $index); ?>">Description:</label>
2916 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][description]" id="course_description_<?php echo esc_attr((string) $index); ?>" rows="4" style="width: 100%;" placeholder="Course description"><?php echo esc_textarea($description); ?></textarea>
2917 </div>
2918
2919 <div class="schema-field">
2920 <label for="course_provider_<?php echo esc_attr((string) $index); ?>">Provider Name: <span style="color: #dc3232;">*</span></label>
2921 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][provider_name]" id="course_provider_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($provider_name); ?>" placeholder="Organization providing the course">
2922 </div>
2923
2924 <div class="schema-field">
2925 <label for="course_provider_url_<?php echo esc_attr((string) $index); ?>">Provider URL:</label>
2926 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][provider_sameAs]" id="course_provider_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($provider_sameAs); ?>" placeholder="https://example.com">
2927 </div>
2928
2929 <div class="schema-field">
2930 <label for="course_mode_<?php echo esc_attr((string) $index); ?>">Course Mode:</label>
2931 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][courseInstance_courseMode]" id="course_mode_<?php echo esc_attr((string) $index); ?>">
2932 <option value="online" <?php selected($courseInstance_courseMode, 'online'); ?>>Online</option>
2933 <option value="onsite" <?php selected($courseInstance_courseMode, 'onsite'); ?>>On-site</option>
2934 <option value="blended" <?php selected($courseInstance_courseMode, 'blended'); ?>>Blended</option>
2935 </select>
2936 </div>
2937
2938 <div class="schema-field">
2939 <label for="course_instructor_<?php echo esc_attr((string) $index); ?>">Instructor Name:</label>
2940 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][courseInstance_instructor_name]" id="course_instructor_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($courseInstance_instructor_name); ?>" placeholder="Instructor name">
2941 </div>
2942 </div>
2943 <?php
2944 }
2945
2946 /**
2947 * Validate Course schema
2948 */
2949 private function validate_course_schema($post_id, $fields)
2950 {
2951 $errors = [];
2952
2953 if (empty($fields['name']) || trim($fields['name']) === '') {
2954 $errors[] = ['field' => 'name', 'message' => 'Course schema requires a <strong>Name</strong>.'];
2955 }
2956 if (empty($fields['provider_name']) || trim($fields['provider_name']) === '') {
2957 $errors[] = ['field' => 'provider_name', 'message' => 'Course schema requires a <strong>Provider Name</strong>.'];
2958 }
2959
2960 return $errors;
2961 }
2962
2963 /**
2964 * Generate Course JSON-LD
2965 */
2966 private function generate_course_json_ld($fields, $post)
2967 {
2968 $data = [];
2969
2970 if (!empty($fields['name'])) {
2971 $data['name'] = $fields['name'];
2972 }
2973 if (!empty($fields['description'])) {
2974 $data['description'] = $fields['description'];
2975 }
2976
2977 if (!empty($fields['provider_name'])) {
2978 $data['provider'] = [
2979 '@type' => 'Organization',
2980 'name' => $fields['provider_name'],
2981 ];
2982 if (!empty($fields['provider_sameAs'])) {
2983 $data['provider']['sameAs'] = $fields['provider_sameAs'];
2984 }
2985 }
2986
2987 if (!empty($fields['courseInstance_courseMode']) || !empty($fields['courseInstance_instructor_name'])) {
2988 $data['hasCourseInstance'] = [
2989 '@type' => 'CourseInstance',
2990 ];
2991 if (!empty($fields['courseInstance_courseMode'])) {
2992 $data['hasCourseInstance']['courseMode'] = $fields['courseInstance_courseMode'];
2993 }
2994 if (!empty($fields['courseInstance_instructor_name'])) {
2995 $data['hasCourseInstance']['instructor'] = [
2996 '@type' => 'Person',
2997 'name' => $fields['courseInstance_instructor_name'],
2998 ];
2999 }
3000 }
3001
3002 return $data;
3003 }
3004
3005 // ======================================================================
3006 // New Schema Type: Organization
3007 // ======================================================================
3008
3009 /**
3010 * Render Organization schema fields
3011 */
3012 private function render_organization_fields($fields, $index = 0)
3013 {
3014 $name = isset($fields['name']) ? $fields['name'] : '';
3015 $url = isset($fields['url']) ? $fields['url'] : '';
3016 $logo = isset($fields['logo']) ? $fields['logo'] : '';
3017 $contactPoint_telephone = isset($fields['contactPoint_telephone']) ? $fields['contactPoint_telephone'] : '';
3018 $contactPoint_contactType = isset($fields['contactPoint_contactType']) ? $fields['contactPoint_contactType'] : '';
3019 $sameAs = isset($fields['sameAs']) ? $fields['sameAs'] : '';
3020
3021 ?>
3022 <div class="schema-field-group">
3023 <h4>Organization Information</h4>
3024
3025 <div class="schema-field">
3026 <label for="org_name_<?php echo esc_attr((string) $index); ?>">Organization Name:</label>
3027 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][name]" id="org_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($name); ?>" placeholder="Organization name">
3028 </div>
3029
3030 <div class="schema-field">
3031 <label for="org_url_<?php echo esc_attr((string) $index); ?>">URL:</label>
3032 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][url]" id="org_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($url); ?>" placeholder="https://example.com">
3033 </div>
3034
3035 <div class="schema-field">
3036 <label for="org_logo_<?php echo esc_attr((string) $index); ?>">Logo URL:</label>
3037 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][logo]" id="org_logo_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($logo); ?>" placeholder="https://example.com/logo.png">
3038 </div>
3039
3040 <div class="schema-field">
3041 <label for="org_phone_<?php echo esc_attr((string) $index); ?>">Contact Telephone:</label>
3042 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][contactPoint_telephone]" id="org_phone_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($contactPoint_telephone); ?>" placeholder="+1-555-1234">
3043 </div>
3044
3045 <div class="schema-field">
3046 <label for="org_contact_type_<?php echo esc_attr((string) $index); ?>">Contact Type:</label>
3047 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][contactPoint_contactType]" id="org_contact_type_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($contactPoint_contactType); ?>" placeholder="customer service">
3048 </div>
3049
3050 <div class="schema-field">
3051 <label for="org_sameas_<?php echo esc_attr((string) $index); ?>">Social Profiles (one URL per line):</label>
3052 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][sameAs]" id="org_sameas_<?php echo esc_attr((string) $index); ?>" rows="4" style="width: 100%;" placeholder="https://facebook.com/...&#10;https://twitter.com/..."><?php echo esc_textarea($sameAs); ?></textarea>
3053 </div>
3054 </div>
3055 <?php
3056 }
3057
3058 /**
3059 * Validate Organization schema
3060 */
3061 private function validate_organization_schema($post_id, $fields)
3062 {
3063 return [];
3064 }
3065
3066 /**
3067 * Generate Organization JSON-LD
3068 */
3069 private function generate_organization_json_ld($fields, $post)
3070 {
3071 $data = [];
3072
3073 if (!empty($fields['name'])) {
3074 $data['name'] = $fields['name'];
3075 }
3076 if (!empty($fields['url'])) {
3077 $data['url'] = $fields['url'];
3078 }
3079 if (!empty($fields['logo'])) {
3080 $data['logo'] = [
3081 '@type' => 'ImageObject',
3082 'url' => $fields['logo'],
3083 ];
3084 }
3085
3086 if (!empty($fields['contactPoint_telephone']) || !empty($fields['contactPoint_contactType'])) {
3087 $data['contactPoint'] = [
3088 '@type' => 'ContactPoint',
3089 ];
3090 if (!empty($fields['contactPoint_telephone'])) {
3091 $data['contactPoint']['telephone'] = $fields['contactPoint_telephone'];
3092 }
3093 if (!empty($fields['contactPoint_contactType'])) {
3094 $data['contactPoint']['contactType'] = $fields['contactPoint_contactType'];
3095 }
3096 }
3097
3098 if (!empty($fields['sameAs'])) {
3099 $urls = array_filter(array_map('trim', explode("\n", $fields['sameAs'])));
3100 if (!empty($urls)) {
3101 $data['sameAs'] = $urls;
3102 }
3103 }
3104
3105 return $data;
3106 }
3107
3108 // ======================================================================
3109 // New Schema Type: Person
3110 // ======================================================================
3111
3112 /**
3113 * Render Person schema fields
3114 */
3115 private function render_person_fields($fields, $index = 0)
3116 {
3117 // Auto-populate from post author if fields are empty
3118 $author_id = 0;
3119 global $post;
3120 if ($post && isset($post->post_author)) {
3121 $author_id = $post->post_author;
3122 }
3123
3124 $name = isset($fields['name']) ? $fields['name'] : '';
3125 $url = isset($fields['url']) ? $fields['url'] : '';
3126 $jobTitle = isset($fields['jobTitle']) ? $fields['jobTitle'] : '';
3127 $worksFor = isset($fields['worksFor']) ? $fields['worksFor'] : '';
3128 $email = isset($fields['email']) ? $fields['email'] : '';
3129 $image = isset($fields['image']) ? $fields['image'] : '';
3130 $sameAs = isset($fields['sameAs']) ? $fields['sameAs'] : '';
3131
3132 // Build auto-populate hints from author profile
3133 $author_name = $author_id ? get_the_author_meta('display_name', $author_id) : '';
3134 $author_url = $author_id ? get_author_posts_url($author_id) : '';
3135 $author_email = $author_id ? get_the_author_meta('user_email', $author_id) : '';
3136 $author_avatar = $author_id ? get_avatar_url($author_id, ['size' => 256]) : '';
3137 $author_desc = $author_id ? get_the_author_meta('description', $author_id) : '';
3138
3139 ?>
3140 <div class="schema-field-group">
3141 <h4>Person Information</h4>
3142 <?php if ($author_id && $author_name): ?>
3143 <p class="description" style="margin-top: 0;">Leave fields blank to auto-populate from author profile (<strong><?php echo esc_html($author_name); ?></strong>) when generating schema output.</p>
3144 <?php endif; ?>
3145
3146 <div class="schema-field">
3147 <label for="person_name_<?php echo esc_attr((string) $index); ?>">Name:</label>
3148 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][name]" id="person_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($name); ?>" placeholder="Full name">
3149 </div>
3150
3151 <div class="schema-field">
3152 <label for="person_url_<?php echo esc_attr((string) $index); ?>">URL:</label>
3153 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][url]" id="person_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($url); ?>" placeholder="https://example.com">
3154 </div>
3155
3156 <div class="schema-field">
3157 <label for="person_jobtitle_<?php echo esc_attr((string) $index); ?>">Job Title:</label>
3158 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][jobTitle]" id="person_jobtitle_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($jobTitle); ?>" placeholder="e.g., Software Engineer">
3159 </div>
3160
3161 <div class="schema-field">
3162 <label for="person_worksfor_<?php echo esc_attr((string) $index); ?>">Works For (Organization Name):</label>
3163 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][worksFor]" id="person_worksfor_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($worksFor); ?>" placeholder="e.g., Search Atlas">
3164 </div>
3165
3166 <div class="schema-field">
3167 <label for="person_email_<?php echo esc_attr((string) $index); ?>">Email:</label>
3168 <input type="email" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][email]" id="person_email_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($email); ?>" placeholder="[email protected]">
3169 </div>
3170
3171 <div class="schema-field">
3172 <label for="person_image_<?php echo esc_attr((string) $index); ?>">Image URL:</label>
3173 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][image]" id="person_image_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($image); ?>" placeholder="https://example.com/photo.jpg">
3174 </div>
3175
3176 <div class="schema-field">
3177 <label for="person_sameas_<?php echo esc_attr((string) $index); ?>">Social Profiles (one URL per line):</label>
3178 <textarea name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][sameAs]" id="person_sameas_<?php echo esc_attr((string) $index); ?>" rows="4" style="width: 100%;" placeholder="https://linkedin.com/in/...&#10;https://twitter.com/..."><?php echo esc_textarea($sameAs); ?></textarea>
3179 </div>
3180 </div>
3181 <?php
3182 }
3183
3184 /**
3185 * Validate Person schema
3186 */
3187 private function validate_person_schema($post_id, $fields)
3188 {
3189 return [];
3190 }
3191
3192 /**
3193 * Generate Person JSON-LD
3194 */
3195 private function generate_person_json_ld($fields, $post)
3196 {
3197 $data = [];
3198
3199 // Auto-populate from post author when fields are empty
3200 $author_id = isset($post->post_author) ? $post->post_author : 0;
3201
3202 $name = !empty($fields['name']) ? $fields['name'] : ($author_id ? get_the_author_meta('display_name', $author_id) : '');
3203 $url = !empty($fields['url']) ? $fields['url'] : ($author_id ? get_author_posts_url($author_id) : '');
3204 $image = !empty($fields['image']) ? $fields['image'] : ($author_id ? get_avatar_url($author_id, ['size' => 256]) : '');
3205
3206 if (!empty($name)) {
3207 $data['name'] = $name;
3208 }
3209 if (!empty($url)) {
3210 $data['url'] = $url;
3211 }
3212 if (!empty($fields['jobTitle'])) {
3213 $data['jobTitle'] = $fields['jobTitle'];
3214 }
3215 if (!empty($fields['worksFor'])) {
3216 $data['worksFor'] = [
3217 '@type' => 'Organization',
3218 'name' => $fields['worksFor'],
3219 ];
3220 }
3221 if (!empty($fields['email'])) {
3222 $data['email'] = $fields['email'];
3223 }
3224 if (!empty($image)) {
3225 $data['image'] = $image;
3226 }
3227
3228 if (!empty($fields['sameAs'])) {
3229 $urls = array_filter(array_map('trim', explode("\n", $fields['sameAs'])));
3230 if (!empty($urls)) {
3231 $data['sameAs'] = $urls;
3232 }
3233 }
3234
3235 return $data;
3236 }
3237
3238 // ======================================================================
3239 // New Schema Type: WebSite
3240 // ======================================================================
3241
3242 /**
3243 * Render WebSite schema fields
3244 */
3245 private function render_website_fields($fields, $index = 0)
3246 {
3247 $name = isset($fields['name']) ? $fields['name'] : '';
3248 $url = isset($fields['url']) ? $fields['url'] : '';
3249 $searchbox_enabled = isset($fields['searchbox_enabled']) ? (bool) $fields['searchbox_enabled'] : false;
3250 $searchbox_query_input = isset($fields['searchbox_query_input']) ? $fields['searchbox_query_input'] : '';
3251
3252 ?>
3253 <div class="schema-field-group">
3254 <h4>Website Information</h4>
3255
3256 <div class="schema-field">
3257 <label for="website_name_<?php echo esc_attr((string) $index); ?>">Website Name:</label>
3258 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][name]" id="website_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($name); ?>" placeholder="Your website name">
3259 </div>
3260
3261 <div class="schema-field">
3262 <label for="website_url_<?php echo esc_attr((string) $index); ?>">URL:</label>
3263 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][url]" id="website_url_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($url); ?>" placeholder="https://example.com">
3264 </div>
3265
3266 <div class="schema-field">
3267 <label>
3268 <input type="checkbox" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][searchbox_enabled]" value="1" <?php checked($searchbox_enabled, true); ?>>
3269 Enable Sitelinks Searchbox
3270 </label>
3271 <p class="description">Adds SearchAction for Google Sitelinks Searchbox in SERPs.</p>
3272 </div>
3273
3274 <div class="schema-field">
3275 <label for="website_query_<?php echo esc_attr((string) $index); ?>">Search URL Template:</label>
3276 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][searchbox_query_input]" id="website_query_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($searchbox_query_input); ?>" placeholder="/?s={search_term_string}">
3277 <p class="description">Search URL template (default: /?s={search_term_string})</p>
3278 </div>
3279 </div>
3280 <?php
3281 }
3282
3283 /**
3284 * Validate WebSite schema
3285 */
3286 private function validate_website_schema($post_id, $fields)
3287 {
3288 return [];
3289 }
3290
3291 /**
3292 * Generate WebSite JSON-LD
3293 */
3294 private function generate_website_json_ld($fields, $post)
3295 {
3296 $data = [];
3297
3298 if (!empty($fields['name'])) {
3299 $data['name'] = $fields['name'];
3300 }
3301 if (!empty($fields['url'])) {
3302 $data['url'] = $fields['url'];
3303 }
3304
3305 if (!empty($fields['searchbox_enabled'])) {
3306 $search_target = !empty($fields['searchbox_query_input']) ? $fields['searchbox_query_input'] : '/?s={search_term_string}';
3307 $base_url = !empty($fields['url']) ? rtrim($fields['url'], '/') : home_url();
3308 $data['potentialAction'] = [
3309 '@type' => 'SearchAction',
3310 'target' => [
3311 '@type' => 'EntryPoint',
3312 'urlTemplate' => $base_url . $search_target,
3313 ],
3314 'query-input' => 'required name=search_term_string',
3315 ];
3316 }
3317
3318 return $data;
3319 }
3320
3321 // ======================================================================
3322 // New Schema Type: NewsArticle
3323 // ======================================================================
3324
3325 /**
3326 * Render NewsArticle schema fields
3327 */
3328 private function render_newsarticle_fields($fields, $index = 0)
3329 {
3330 $organization_name = isset($fields['organization_name']) ? $fields['organization_name'] : '';
3331 $organization_logo = isset($fields['organization_logo']) ? $fields['organization_logo'] : '';
3332
3333 ?>
3334 <div class="schema-field-group">
3335 <?php $this->render_override_fields_section($fields, $index, 'NewsArticle', true); ?>
3336
3337 <h4>News Article Information</h4>
3338 <p class="description" style="margin-top: 0;">Optional fields to enhance your news article schema.</p>
3339
3340 <div class="schema-field">
3341 <label for="newsarticle_organization_name_<?php echo esc_attr((string) $index); ?>">Organization Name:</label>
3342 <input type="text" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][organization_name]" id="newsarticle_organization_name_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($organization_name); ?>" placeholder="e.g., News Corp">
3343 <p class="description">The name of the organization that published this article.</p>
3344 </div>
3345
3346 <div class="schema-field">
3347 <label for="newsarticle_organization_logo_<?php echo esc_attr((string) $index); ?>">Organization Logo:</label>
3348 <div class="logo-upload-container">
3349 <input type="url" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][organization_logo]" id="newsarticle_organization_logo_<?php echo esc_attr((string) $index); ?>" value="<?php echo esc_attr($organization_logo); ?>" placeholder="https://example.com/logo.png" style="width: 70%; margin-right: 10px;">
3350 </div>
3351 <p class="description">Logo of the organization (recommended for better rich results).</p>
3352 </div>
3353 </div>
3354 <?php
3355 }
3356
3357 /**
3358 * Validate NewsArticle schema (same checks as Article)
3359 */
3360 private function validate_newsarticle_schema($post_id, $fields)
3361 {
3362 $errors = [];
3363
3364 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
3365 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
3366 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
3367
3368 if (empty($effective_title) || trim($effective_title) === '') {
3369 $errors[] = [
3370 'field' => 'headline',
3371 'message' => 'News Article schema requires a <strong>Headline</strong>. Please add a post title or override it in the Override Default Fields section.'
3372 ];
3373 }
3374
3375 if (empty($effective_description) || trim($effective_description) === '') {
3376 $errors[] = [
3377 'field' => 'description',
3378 'message' => 'News Article schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
3379 ];
3380 }
3381
3382 if (empty($effective_image)) {
3383 $errors[] = [
3384 'field' => 'image',
3385 'message' => 'News Article schema requires an <strong>Image</strong>. Please set a featured image or override it in the Override Default Fields section.'
3386 ];
3387 }
3388
3389 return $errors;
3390 }
3391
3392 /**
3393 * Generate NewsArticle JSON-LD (same shape as Article)
3394 */
3395 private function generate_newsarticle_json_ld($fields, $post)
3396 {
3397 return $this->generate_article_json_ld($fields, $post);
3398 }
3399
3400 // ======================================================================
3401 // Cross-plugin schema deduplication
3402 // ======================================================================
3403
3404 /**
3405 * Check if a conflicting SEO plugin already outputs this schema type.
3406 *
3407 * Per-type suppression: only suppress the specific @type that a competing
3408 * plugin is known to output, not all MetaSync schema.
3409 *
3410 * The type key is matched case-insensitively. Stored per-post type keys use
3411 * mixed casing — the metabox dropdown and the MCP tool enum both offer
3412 * 'article', 'product' and 'recipe' in lowercase while the remaining keys
3413 * ('LocalBusiness', 'NewsArticle', 'Organization', …) keep their Schema.org
3414 * casing — and the hardcoded callers in output_schema_markup() pass the
3415 * Schema.org spelling. Comparing the raw value against a single casing can
3416 * therefore only ever match part of the key set, so both sides are folded to
3417 * lowercase here. This mirrors the strtolower() matching the external
3418 * importer already uses on incoming schema types.
3419 *
3420 * @param string $type Schema.org type name or stored schema type key
3421 * @return bool True if MetaSync should output this type; false to suppress
3422 */
3423 private function should_output_schema_type($type)
3424 {
3425 $type_key = strtolower((string) $type);
3426
3427 // Yoast SEO — outputs these types via its schema graph
3428 if (class_exists('WPSEO_Schema_Context')) {
3429 $yoast_types = ['article', 'newsarticle', 'website', 'organization', 'person', 'webpage', 'breadcrumblist'];
3430 if (in_array($type_key, $yoast_types, true)) {
3431 return false;
3432 }
3433 }
3434
3435 // Yoast Local SEO — specifically for LocalBusiness
3436 if ($type_key === 'localbusiness' && class_exists('WPSEO_Local_Core')) {
3437 return false;
3438 }
3439
3440 // Rank Math — outputs these types via its schema module
3441 if (class_exists('RankMath\\Schema\\JsonLD')) {
3442 $rankmath_types = ['article', 'newsarticle', 'website', 'organization', 'person', 'webpage', 'breadcrumblist'];
3443 if (in_array($type_key, $rankmath_types, true)) {
3444 return false;
3445 }
3446 }
3447
3448 // AIOSEO — outputs these types when its schema feature is active
3449 if (has_filter('aioseo_schema')) {
3450 $aioseo_types = ['article', 'newsarticle', 'website', 'organization', 'person', 'webpage', 'breadcrumblist'];
3451 if (in_array($type_key, $aioseo_types, true)) {
3452 return false;
3453 }
3454 }
3455
3456 return true;
3457 }
3458
3459 // ======================================================================
3460 // WooCommerce integration for Product schema
3461 // ======================================================================
3462
3463 /**
3464 * Generate LocalBusiness JSON-LD
3465 */
3466 private function generate_local_business_json_ld($fields)
3467 {
3468 // Deduplication: suppress if Yoast Local SEO is active
3469 // (also checked upstream in dedup_check_for_type, but kept as safety net)
3470 if (class_exists('WPSEO_Local_Core')) {
3471 return false;
3472 }
3473
3474 $data = [];
3475
3476 if (!empty($fields['business_name'])) {
3477 $data['name'] = $fields['business_name'];
3478 }
3479
3480 if (!empty($fields['description'])) {
3481 $data['description'] = $fields['description'];
3482 }
3483
3484 if (!empty($fields['url'])) {
3485 $data['url'] = $fields['url'];
3486 }
3487
3488 if (!empty($fields['telephone'])) {
3489 $data['telephone'] = $fields['telephone'];
3490 }
3491
3492 if (!empty($fields['price_range'])) {
3493 $data['priceRange'] = $fields['price_range'];
3494 }
3495
3496 if (!empty($fields['image'])) {
3497 $data['image'] = $fields['image'];
3498 }
3499
3500 // Build PostalAddress sub-object
3501 $address = [];
3502 if (!empty($fields['street_address'])) {
3503 $address['streetAddress'] = $fields['street_address'];
3504 }
3505 if (!empty($fields['city'])) {
3506 $address['addressLocality'] = $fields['city'];
3507 }
3508 if (!empty($fields['state'])) {
3509 $address['addressRegion'] = $fields['state'];
3510 }
3511 if (!empty($fields['postal_code'])) {
3512 $address['postalCode'] = $fields['postal_code'];
3513 }
3514 if (!empty($fields['country'])) {
3515 $address['addressCountry'] = $fields['country'];
3516 }
3517 if (!empty($address)) {
3518 $address['@type'] = 'PostalAddress';
3519 $data['address'] = $address;
3520 }
3521
3522 // Build GeoCoordinates sub-object
3523 if (!empty($fields['latitude']) && !empty($fields['longitude'])) {
3524 $data['geo'] = [
3525 '@type' => 'GeoCoordinates',
3526 'latitude' => $fields['latitude'],
3527 'longitude' => $fields['longitude'],
3528 ];
3529 }
3530
3531 // Build openingHoursSpecification
3532 if (!empty($fields['opening_hours']) && is_array($fields['opening_hours'])) {
3533 $specs = [];
3534 foreach ($fields['opening_hours'] as $hours) {
3535 if (!empty($hours['day'])) {
3536 $spec = [
3537 '@type' => 'OpeningHoursSpecification',
3538 'dayOfWeek' => $hours['day'],
3539 ];
3540 if (!empty($hours['open'])) {
3541 $spec['opens'] = $hours['open'];
3542 }
3543 if (!empty($hours['close'])) {
3544 $spec['closes'] = $hours['close'];
3545 }
3546 $specs[] = $spec;
3547 }
3548 }
3549 if (!empty($specs)) {
3550 $data['openingHoursSpecification'] = $specs;
3551 }
3552 }
3553
3554 return $data;
3555 }
3556
3557 /**
3558 * Generate HowTo JSON-LD
3559 */
3560 private function generate_howto_json_ld($fields)
3561 {
3562 $data = [];
3563
3564 // Steps
3565 if (!empty($fields['steps']) && is_array($fields['steps'])) {
3566 $data['step'] = [];
3567 foreach ($fields['steps'] as $step) {
3568 if (!empty($step['instructions'])) {
3569 $step_data = [
3570 '@type' => 'HowToStep',
3571 'text' => $step['instructions'],
3572 ];
3573 if (!empty($step['image'])) {
3574 $step_data['image'] = $step['image'];
3575 }
3576 $data['step'][] = $step_data;
3577 }
3578 }
3579 }
3580
3581 // Supplies
3582 if (!empty($fields['supplies']) && is_array($fields['supplies'])) {
3583 $data['supply'] = [];
3584 foreach ($fields['supplies'] as $supply) {
3585 if (!empty($supply)) {
3586 $data['supply'][] = [
3587 '@type' => 'HowToSupply',
3588 'name' => $supply,
3589 ];
3590 }
3591 }
3592 }
3593
3594 // Tools
3595 if (!empty($fields['tools']) && is_array($fields['tools'])) {
3596 $data['tool'] = [];
3597 foreach ($fields['tools'] as $tool) {
3598 if (!empty($tool)) {
3599 $data['tool'][] = [
3600 '@type' => 'HowToTool',
3601 'name' => $tool,
3602 ];
3603 }
3604 }
3605 }
3606
3607 // Total time (ISO 8601 duration)
3608 if (!empty($fields['total_time'])) {
3609 $data['totalTime'] = 'PT' . $fields['total_time'] . 'M';
3610 }
3611
3612 // Estimated cost
3613 if (!empty($fields['estimated_cost'])) {
3614 $data['estimatedCost'] = [
3615 '@type' => 'MonetaryAmount',
3616 'value' => $fields['estimated_cost'],
3617 ];
3618 }
3619
3620 return $data;
3621 }
3622
3623 /**
3624 * Generate VideoObject JSON-LD
3625 */
3626 private function generate_video_object_json_ld($fields)
3627 {
3628 $data = [];
3629
3630 if (!empty($fields['video_name'])) {
3631 $data['name'] = $fields['video_name'];
3632 }
3633
3634 if (!empty($fields['video_description'])) {
3635 $data['description'] = $fields['video_description'];
3636 }
3637
3638 if (!empty($fields['thumbnail_url'])) {
3639 $data['thumbnailUrl'] = $fields['thumbnail_url'];
3640 }
3641
3642 if (!empty($fields['upload_date'])) {
3643 $data['uploadDate'] = $fields['upload_date'];
3644 }
3645
3646 if (!empty($fields['content_url'])) {
3647 $data['contentUrl'] = $fields['content_url'];
3648 }
3649
3650 if (!empty($fields['embed_url'])) {
3651 $data['embedUrl'] = $fields['embed_url'];
3652 }
3653
3654 if (!empty($fields['duration'])) {
3655 $data['duration'] = 'PT' . $fields['duration'] . 'M';
3656 }
3657
3658 return $data;
3659 }
3660
3661 /**
3662 * Enqueue admin scripts
3663 */
3664 public function enqueue_admin_scripts($hook)
3665 {
3666 // Only load on post/page edit screens
3667 if ($hook !== 'post.php' && $hook !== 'post-new.php') {
3668 return;
3669 }
3670
3671 wp_enqueue_script('jquery');
3672
3673 // Enqueue WordPress media scripts for image uploader
3674 wp_enqueue_media();
3675
3676 // Enqueue schema markup admin CSS
3677 wp_enqueue_style(
3678 'metasync-schema-markup-admin',
3679 plugin_dir_url(__FILE__) . 'css/schema-markup-admin.css',
3680 [],
3681 $this->version,
3682 'all'
3683 );
3684
3685 // Enqueue schema markup admin JS
3686 wp_enqueue_script(
3687 'metasync-schema-markup-admin',
3688 plugin_dir_url(__FILE__) . 'js/schema-markup-admin.js',
3689 ['jquery', 'wp-util'],
3690 $this->version,
3691 true
3692 );
3693
3694 // Localize script with nonces
3695 wp_localize_script(
3696 'metasync-schema-markup-admin',
3697 'metasyncSchemaMarkup',
3698 [
3699 'ajaxurl' => admin_url('admin-ajax.php'),
3700 'schema_fields_nonce' => wp_create_nonce('metasync_schema_fields_nonce'),
3701 'preview_schema_nonce' => wp_create_nonce('metasync_preview_schema_nonce')
3702 ]
3703 );
3704 }
3705
3706 /**
3707 * Post types the schema markup UI is registered for
3708 *
3709 * Single source of truth shared by the meta box registration and the AJAX
3710 * handlers so the two cannot drift apart.
3711 *
3712 * @return array List of supported post type slugs
3713 */
3714 private function get_schema_supported_post_types()
3715 {
3716 return ['post', 'page'];
3717 }
3718
3719 /**
3720 * Resolve and authorize the post targeted by a schema markup AJAX request
3721 *
3722 * Terminates the request with a JSON error when the current user may not
3723 * edit the requested post, when the post does not exist, or when the post
3724 * type is not one the schema UI applies to. A missing post and a forbidden
3725 * post return the same 403 response so the endpoint cannot be used to
3726 * enumerate which post IDs exist.
3727 *
3728 * @param int $post_id Requested post ID
3729 * @return WP_Post The authorized post object
3730 */
3731 private function authorize_schema_post_request($post_id)
3732 {
3733 $post = get_post($post_id);
3734
3735 if (!$post || !current_user_can('edit_post', $post_id)) {
3736 wp_send_json_error(['message' => 'Permission denied'], 403);
3737 } elseif (!in_array($post->post_type, $this->get_schema_supported_post_types(), true)) {
3738 wp_send_json_error(['message' => 'Unsupported post type'], 400);
3739 }
3740
3741 return $post;
3742 }
3743
3744 /**
3745 * AJAX handler for getting schema fields
3746 */
3747 public function ajax_get_schema_fields()
3748 {
3749 // Verify nonce
3750 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_schema_fields_nonce')) {
3751 wp_send_json_error(['message' => 'Security check failed'], 403);
3752 }
3753
3754 $schema_type = isset($_POST['schema_type']) ? sanitize_text_field(wp_unslash($_POST['schema_type'])) : '';
3755 $index = isset($_POST['index']) ? intval($_POST['index']) : 0;
3756 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
3757
3758 // Set global post for override fields rendering
3759 if ($post_id) {
3760 global $post;
3761 $post = $this->authorize_schema_post_request($post_id);
3762 } elseif (!current_user_can('edit_posts')) {
3763 // No post context: the markup rendered below carries no post data,
3764 // but the editor UI is still limited to users who may author posts.
3765 wp_send_json_error(['message' => 'Permission denied'], 403);
3766 }
3767
3768 ob_start();
3769 $this->render_schema_fields($schema_type, [], $index);
3770 $html = ob_get_clean();
3771
3772 wp_send_json_success($html);
3773 }
3774
3775 /**
3776 * AJAX handler for previewing schema markup
3777 */
3778 public function ajax_preview_schema()
3779 {
3780 // Verify nonce
3781 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_preview_schema_nonce')) {
3782 wp_send_json_error(['message' => 'Security check failed'], 403);
3783 }
3784
3785 // Get post ID
3786 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
3787 if (!$post_id) {
3788 wp_send_json_error(['message' => 'Invalid post ID']);
3789 }
3790
3791 // Get post object, enforcing that the current user may edit it
3792 $post = $this->authorize_schema_post_request($post_id);
3793
3794 // Check if schema is enabled
3795 $schema_enabled = isset($_POST['schema_enabled']) && rest_sanitize_boolean(wp_unslash($_POST['schema_enabled']));
3796 if (!$schema_enabled) {
3797 wp_send_json_error(['message' => 'Schema markup is not enabled']);
3798 }
3799
3800 // Get schema types data
3801 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every field of every entry is sanitised per-type in sanitize_schema_fields() below before anything is stored.
3802 $schema_types = isset($_POST['schema_types']) ? wp_unslash($_POST['schema_types']) : [];
3803 if (empty($schema_types)) {
3804 wp_send_json_error(['message' => 'No schema types provided']);
3805 }
3806
3807 // Build schema data array
3808 $schema_data = [
3809 'enabled' => true,
3810 'types' => []
3811 ];
3812
3813 foreach ($schema_types as $type_data) {
3814 if (!empty($type_data['type'])) {
3815 $schema_type = sanitize_text_field($type_data['type']);
3816 $fields = isset($type_data['fields']) ? $type_data['fields'] : [];
3817 $sanitized_fields = $this->sanitize_schema_fields($fields, $schema_type);
3818
3819 $schema_data['types'][] = [
3820 'type' => $schema_type,
3821 'fields' => $sanitized_fields
3822 ];
3823 }
3824 }
3825
3826 // Validate schema requirements before generating preview
3827 $all_validation_errors = [];
3828 foreach ($schema_data['types'] as $schema_type_data) {
3829 $validation_errors = $this->validate_schema_requirements($post_id, $schema_type_data['type'], $schema_type_data['fields']);
3830 if (!empty($validation_errors)) {
3831 // Group errors by schema type with display-friendly names
3832 $schema_type_display_names = [
3833 'article' => 'Article',
3834 'FAQPage' => 'FAQ',
3835 'product' => 'Product',
3836 'recipe' => 'Recipe',
3837 'Event' => 'Event',
3838 'JobPosting' => 'Job Posting',
3839 'Review' => 'Review',
3840 'Course' => 'Course',
3841 'Organization' => 'Organization',
3842 'Person' => 'Person',
3843 'WebSite' => 'Website',
3844 'NewsArticle' => 'News Article',
3845 'LocalBusiness' => 'Local Business',
3846 'HowTo' => 'How-To',
3847 'VideoObject' => 'Video Object'
3848 ];
3849 $schema_type_name = isset($schema_type_display_names[$schema_type_data['type']])
3850 ? $schema_type_display_names[$schema_type_data['type']]
3851 : ucfirst($schema_type_data['type']);
3852
3853 foreach ($validation_errors as $error) {
3854 $all_validation_errors[] = [
3855 'schema_type' => $schema_type_name,
3856 'field' => $error['field'],
3857 'message' => $error['message']
3858 ];
3859 }
3860 }
3861 }
3862
3863 // If there are validation errors, return them instead of generating preview
3864 if (!empty($all_validation_errors)) {
3865 wp_send_json_error([
3866 'message' => 'Validation errors detected',
3867 'validation_errors' => $all_validation_errors
3868 ]);
3869 }
3870
3871 // Generate JSON-LD for all schema types
3872 $all_json_ld = [];
3873 foreach ($schema_data['types'] as $schema_type_data) {
3874 $json_ld = $this->generate_json_ld($post, $schema_type_data);
3875 if ($json_ld) {
3876 $all_json_ld[] = $json_ld;
3877 }
3878 }
3879
3880 if (empty($all_json_ld)) {
3881 wp_send_json_error(['message' => 'Could not generate schema markup']);
3882 }
3883
3884 // Build the final JSON-LD structure with @context and @graph
3885 $final_json_ld = [
3886 '@context' => 'https://schema.org',
3887 '@graph' => $all_json_ld
3888 ];
3889
3890 $formatted_json = metasync_safe_json_ld_encode($final_json_ld, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
3891 if (!is_string($formatted_json) || $formatted_json === '') {
3892 wp_send_json_error(['message' => 'Could not encode schema markup']);
3893 }
3894
3895 // Wrap in script tags for preview
3896 $formatted_json_with_tags = '<script type="application/ld+json" class="metasync-schema">' . "\n" . $formatted_json . "\n" . '</script>';
3897
3898 wp_send_json_success([
3899 'json' => $formatted_json_with_tags,
3900 'types' => array_column($schema_data['types'], 'type')
3901 ]);
3902 }
3903
3904 /**
3905 * Get the plugin name
3906 *
3907 * @return string The plugin name
3908 */
3909 public function get_plugin_name()
3910 {
3911 return $this->plugin_name;
3912 }
3913
3914 /**
3915 * Get the plugin version
3916 *
3917 * @return string The plugin version
3918 */
3919 public function get_version()
3920 {
3921 return $this->version;
3922 }
3923
3924 /**
3925 * Register REST API routes for schema content
3926 */
3927 public function register_schema_content_rest_routes()
3928 {
3929 register_rest_route('metasync/v1', '/schema-content/(?P<post_id>\d+)', [
3930 [
3931 'methods' => 'GET',
3932 'callback' => [$this, 'handle_rest_get_schema_content'],
3933 'permission_callback' => function () {
3934 return current_user_can('edit_posts');
3935 },
3936 'args' => [
3937 'post_id' => [
3938 'required' => true,
3939 'validate_callback' => function ($param) {
3940 return is_numeric($param) && intval($param) > 0;
3941 },
3942 'sanitize_callback' => 'absint',
3943 ],
3944 ],
3945 ],
3946 [
3947 'methods' => 'POST',
3948 'callback' => [$this, 'handle_rest_set_schema_content'],
3949 'permission_callback' => function () {
3950 return current_user_can('edit_posts');
3951 },
3952 'args' => [
3953 'post_id' => [
3954 'required' => true,
3955 'validate_callback' => function ($param) {
3956 return is_numeric($param) && intval($param) > 0;
3957 },
3958 'sanitize_callback' => 'absint',
3959 ],
3960 ],
3961 ],
3962 ]);
3963 }
3964
3965 /**
3966 * Handle GET request for schema content
3967 *
3968 * @param WP_REST_Request $request
3969 * @return WP_REST_Response
3970 */
3971 public function handle_rest_get_schema_content(WP_REST_Request $request)
3972 {
3973 $post_id = $request->get_param('post_id');
3974
3975 $post = get_post($post_id);
3976 if (!$post) {
3977 return new WP_REST_Response(['error' => 'Post not found'], 404);
3978 }
3979
3980 if (!current_user_can('edit_post', $post_id)) {
3981 return new WP_REST_Response(['error' => 'Permission denied'], 403);
3982 }
3983
3984 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
3985
3986 $result = [
3987 'post_id' => $post_id,
3988 'schema_enabled' => false,
3989 'types' => [],
3990 ];
3991
3992 if (!empty($schema_data)) {
3993 $result['schema_enabled'] = isset($schema_data['enabled']) ? (bool) $schema_data['enabled'] : false;
3994 if (!empty($schema_data['types']) && is_array($schema_data['types'])) {
3995 $result['types'] = $schema_data['types'];
3996 }
3997 }
3998
3999 return new WP_REST_Response($result, 200);
4000 }
4001
4002 /**
4003 * Handle POST request to set schema content for a specific type
4004 *
4005 * Expects JSON body: { "schema_type": "FAQPage", "fields": { ... } }
4006 *
4007 * @param WP_REST_Request $request
4008 * @return WP_REST_Response
4009 */
4010 public function handle_rest_set_schema_content(WP_REST_Request $request)
4011 {
4012 $post_id = $request->get_param('post_id');
4013 $schema_type = sanitize_text_field($request->get_param('schema_type'));
4014 $fields = $request->get_param('fields');
4015
4016 if (empty($schema_type)) {
4017 return new WP_REST_Response(['error' => 'schema_type is required'], 400);
4018 }
4019
4020 if (!in_array($schema_type, self::$supported_content_types, true)) {
4021 return new WP_REST_Response(['error' => 'Unsupported schema_type: ' . $schema_type], 400);
4022 }
4023
4024 $post = get_post($post_id);
4025 if (!$post) {
4026 return new WP_REST_Response(['error' => 'Post not found'], 404);
4027 }
4028
4029 if (!current_user_can('edit_post', $post_id)) {
4030 return new WP_REST_Response(['error' => 'Permission denied'], 403);
4031 }
4032
4033 // Sanitize fields before saving (same as Classic Editor path)
4034 $fields = $this->sanitize_schema_fields(is_array($fields) ? $fields : [], $schema_type);
4035
4036 // Get existing schema data
4037 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
4038 if (empty($schema_data)) {
4039 $schema_data = ['enabled' => true, 'types' => []];
4040 }
4041
4042 // Find and update or add the schema type
4043 $found = false;
4044 foreach ($schema_data['types'] as &$type_data) {
4045 if ($type_data['type'] === $schema_type) {
4046 $type_data['fields'] = $fields;
4047 $found = true;
4048 break;
4049 }
4050 }
4051 unset($type_data);
4052
4053 if (!$found) {
4054 $schema_data['types'][] = [
4055 'type' => $schema_type,
4056 'fields' => $fields,
4057 ];
4058 }
4059
4060 // Save updated schema data
4061 update_post_meta($post_id, 'metasync_schema_markup', $schema_data);
4062
4063 // Validate
4064 $validation_warnings = $this->validate_schema_requirements($post_id, $schema_type, $fields);
4065
4066 // Update validation errors in post meta
4067 if (empty($validation_warnings)) {
4068 delete_post_meta($post_id, '_metasync_schema_validation_errors');
4069 } else {
4070 update_post_meta($post_id, '_metasync_schema_validation_errors', $validation_warnings);
4071 }
4072
4073 // Cross-plugin sync
4074 $this->sync_schema_to_seo_plugins($post_id, $schema_type, $fields);
4075
4076 return new WP_REST_Response([
4077 'post_id' => $post_id,
4078 'schema_type' => $schema_type,
4079 'message' => 'Schema content updated successfully',
4080 'validation_warnings' => $validation_warnings,
4081 ], 200);
4082 }
4083
4084 /**
4085 * Sync schema data to third-party SEO plugins (Yoast, Rank Math)
4086 *
4087 * @param int $post_id
4088 * @param string $type Schema type
4089 * @param array $fields Schema fields
4090 */
4091 public function sync_schema_to_seo_plugins($post_id, $type, $fields)
4092 {
4093 // Sync to Yoast SEO
4094 if (defined('WPSEO_VERSION')) {
4095 try {
4096 $this->sync_schema_to_yoast($post_id, $type, $fields);
4097 } catch (\Exception $e) {
4098 error_log('MetaSync: Yoast schema sync failed for post ' . $post_id . ' — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4099 }
4100 }
4101
4102 // Sync to Rank Math
4103 if (defined('RANK_MATH_VERSION')) {
4104 try {
4105 $this->sync_schema_to_rank_math($post_id, $type, $fields);
4106 } catch (\Exception $e) {
4107 error_log('MetaSync: Rank Math schema sync failed for post ' . $post_id . ' — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4108 }
4109 }
4110 }
4111
4112 /**
4113 * Build a schema.org-compatible block array for a given type and fields.
4114 * Used by both Yoast and Rank Math sync methods to avoid duplicating
4115 * the field-mapping logic.
4116 *
4117 * @param int $post_id
4118 * @param string $type
4119 * @param array $fields
4120 * @return array|null Schema block array, or null if required fields are missing.
4121 */
4122 private function build_schema_block($post_id, $type, $fields)
4123 {
4124 switch ($type) {
4125 case 'FAQPage':
4126 if (empty($fields['faq_items']) || !is_array($fields['faq_items'])) {
4127 return null;
4128 }
4129 $main_entity = [];
4130 foreach ($fields['faq_items'] as $item) {
4131 if (!empty($item['question'])) {
4132 $main_entity[] = [
4133 '@type' => 'Question',
4134 'name' => $item['question'],
4135 'acceptedAnswer' => [
4136 '@type' => 'Answer',
4137 'text' => isset($item['answer']) ? $item['answer'] : '',
4138 ],
4139 ];
4140 }
4141 }
4142 if (empty($main_entity)) {
4143 return null;
4144 }
4145 return [
4146 '@type' => 'FAQPage',
4147 'mainEntity' => $main_entity,
4148 ];
4149
4150 case 'HowTo':
4151 if (empty($fields['steps']) || !is_array($fields['steps'])) {
4152 return null;
4153 }
4154 $steps = [];
4155 foreach ($fields['steps'] as $i => $step) {
4156 if (!empty($step['instructions'])) {
4157 $step_data = [
4158 '@type' => 'HowToStep',
4159 'position' => $i + 1,
4160 'text' => $step['instructions'],
4161 ];
4162 if (!empty($step['image'])) {
4163 $step_data['image'] = $step['image'];
4164 }
4165 $steps[] = $step_data;
4166 }
4167 }
4168 if (empty($steps)) {
4169 return null;
4170 }
4171 $block = [
4172 '@type' => 'HowTo',
4173 'name' => get_the_title($post_id),
4174 'step' => $steps,
4175 ];
4176 if (!empty($fields['total_time'])) {
4177 $block['totalTime'] = 'PT' . intval($fields['total_time']) . 'M';
4178 }
4179 return $block;
4180
4181 case 'product':
4182 if (empty($fields['price']) || floatval($fields['price']) <= 0) {
4183 return null;
4184 }
4185 $block = [
4186 '@type' => 'Product',
4187 'name' => get_the_title($post_id),
4188 'offers' => [
4189 '@type' => 'Offer',
4190 'price' => floatval($fields['price']),
4191 'priceCurrency' => !empty($fields['currency']) ? $fields['currency'] : 'USD',
4192 'availability' => !empty($fields['availability'])
4193 ? 'https://schema.org/' . $fields['availability']
4194 : 'https://schema.org/InStock',
4195 ],
4196 ];
4197 if (!empty($fields['sku'])) {
4198 $block['sku'] = $fields['sku'];
4199 }
4200 if (!empty($fields['brand'])) {
4201 $block['brand'] = ['@type' => 'Brand', 'name' => $fields['brand']];
4202 }
4203 return $block;
4204
4205 case 'recipe':
4206 if (empty($fields['ingredients']) && empty($fields['instructions'])) {
4207 return null;
4208 }
4209 $block = [
4210 '@type' => 'Recipe',
4211 'name' => get_the_title($post_id),
4212 ];
4213 if (!empty($fields['ingredients']) && is_array($fields['ingredients'])) {
4214 $block['recipeIngredient'] = $fields['ingredients'];
4215 }
4216 if (!empty($fields['instructions']) && is_array($fields['instructions'])) {
4217 $inst_steps = [];
4218 foreach ($fields['instructions'] as $inst) {
4219 $text = is_array($inst) ? (isset($inst['text']) ? $inst['text'] : '') : $inst;
4220 if (!empty($text)) {
4221 $inst_steps[] = ['@type' => 'HowToStep', 'text' => $text];
4222 }
4223 }
4224 $block['recipeInstructions'] = $inst_steps;
4225 }
4226 if (!empty($fields['yield'])) {
4227 $block['recipeYield'] = $fields['yield'];
4228 }
4229 if (!empty($fields['prep_time'])) {
4230 $block['prepTime'] = 'PT' . intval($fields['prep_time']) . 'M';
4231 }
4232 if (!empty($fields['cook_time'])) {
4233 $block['cookTime'] = 'PT' . intval($fields['cook_time']) . 'M';
4234 }
4235 if (!empty($fields['total_time'])) {
4236 $block['totalTime'] = 'PT' . intval($fields['total_time']) . 'M';
4237 }
4238 if (!empty($fields['calories'])) {
4239 $block['nutrition'] = ['@type' => 'NutritionInformation', 'calories' => $fields['calories']];
4240 }
4241 return $block;
4242 }
4243
4244 return null;
4245 }
4246
4247 /**
4248 * Sync schema to Yoast SEO
4249 *
4250 * @param int $post_id
4251 * @param string $type
4252 * @param array $fields
4253 */
4254 private function sync_schema_to_yoast($post_id, $type, $fields)
4255 {
4256 $schema_block = $this->build_schema_block($post_id, $type, $fields);
4257 if ($schema_block === null) {
4258 return;
4259 }
4260
4261 // Yoast stores its custom schema blocks as a JSON-encoded array in this meta key.
4262 // We replace or append our block within that array.
4263 $yoast_schema = get_post_meta($post_id, '_yoast_wpseo_schema', true);
4264 if (is_string($yoast_schema) && !empty($yoast_schema)) {
4265 $decoded = json_decode($yoast_schema, true);
4266 $yoast_schema = is_array($decoded) ? $decoded : [];
4267 }
4268 if (!is_array($yoast_schema)) {
4269 $yoast_schema = [];
4270 }
4271
4272 $replaced = false;
4273 foreach ($yoast_schema as &$existing) {
4274 if (isset($existing['@type']) && $existing['@type'] === $schema_block['@type']) {
4275 $existing = $schema_block;
4276 $replaced = true;
4277 break;
4278 }
4279 }
4280 unset($existing);
4281
4282 if (!$replaced) {
4283 $yoast_schema[] = $schema_block;
4284 }
4285
4286 $encoded = wp_json_encode($yoast_schema);
4287
4288 // Consent gate and write-once backup — Yoast's schema meta belongs to
4289 // Yoast, so overwriting it asks the same permission every other
4290 // third-party write does and preserves the block that was there before.
4291 if (class_exists('Metasync_Seo_Backup')) {
4292 Metasync_Seo_Backup::write_post_meta($post_id, '_yoast_wpseo_schema', $encoded);
4293 }
4294 }
4295
4296 /**
4297 * Sync schema to Rank Math
4298 *
4299 * @param int $post_id
4300 * @param string $type
4301 * @param array $fields
4302 */
4303 private function sync_schema_to_rank_math($post_id, $type, $fields)
4304 {
4305 $rm_data = $this->build_schema_block($post_id, $type, $fields);
4306 if ($rm_data === null) {
4307 return;
4308 }
4309
4310 $meta_key = 'rank_math_schema_' . $rm_data['@type'];
4311
4312 // Consent gate and write-once backup, as for Yoast above.
4313 if (class_exists('Metasync_Seo_Backup')) {
4314 Metasync_Seo_Backup::write_post_meta($post_id, $meta_key, $rm_data);
4315 }
4316 }
4317
4318 /**
4319 * Get images for schema markup
4320 * Currently returns featured image, but designed to support multiple sources in future
4321 *
4322 * @param int $post_id The post ID
4323 * @return array Array of image URLs
4324 */
4325 private function get_post_images($post_id)
4326 {
4327 $images = [];
4328
4329 // 1. Featured Image (Post Thumbnail)
4330 $featured_image_id = get_post_thumbnail_id($post_id);
4331 if ($featured_image_id) {
4332 $featured_image_url = wp_get_attachment_image_url($featured_image_id, 'full');
4333 if ($featured_image_url) {
4334 $images[] = $featured_image_url;
4335 }
4336 }
4337
4338 // Future expansion points:
4339 // 2. Gallery images from post content
4340 // 3. Images from post attachments
4341 // 4. WooCommerce product gallery (if WooCommerce is active)
4342 // 5. Custom field images
4343 // 6. Images from post content (img tags)
4344
4345 return $images;
4346 }
4347
4348 }
4349