PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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.2, at schema-markup/class-metasync-schema-markup.php

4,387 lines 214.1 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 // A type posted without a fields key is the "Add Schema Type"
1023 // block saved while its metasync_get_schema_fields AJAX was
1024 // still in flight — the form carries only the hidden [type]
1025 // input until that response lands. Persisting [] there
1026 // stored blank overrides, wrote false "requires a Headline"
1027 // validation errors, and suppressed the post's schema output.
1028 // Fall back to the same placeholder defaults the field
1029 // markup renders for a brand-new type, so an immediate save
1030 // stores exactly what waiting for the fields would have.
1031 $schema_fields = isset($type_data['fields'])
1032 ? $this->sanitize_schema_fields($type_data['fields'], $schema_type)
1033 : $this->sanitize_schema_fields($this->get_unsubmitted_schema_field_defaults(), $schema_type);
1034
1035 $schema_data['types'][] = [
1036 'type' => $schema_type,
1037 'fields' => $schema_fields
1038 ];
1039
1040 // Mark this type as seen
1041 $seen_types[] = $schema_type;
1042 }
1043 }
1044 }
1045 }
1046
1047 // If checkbox is unchecked, preserve existing schema data but mark as disabled
1048 if (empty($schema_data['enabled'])) {
1049 if (!empty($existing_schema_data) && !empty($existing_schema_data['types'])) {
1050 // Preserve existing schema types but mark as disabled
1051 $schema_data['enabled'] = false;
1052 $schema_data['types'] = $existing_schema_data['types'];
1053 }
1054 }
1055
1056 // Delete any existing validation errors
1057 delete_post_meta($post_id, '_metasync_schema_validation_errors');
1058
1059 // Save the meta (only validate if enabled)
1060 if (!empty($schema_data['enabled']) && !empty($schema_data['types'])) {
1061 // Validate schema requirements for all types
1062 $all_validation_errors = [];
1063
1064 foreach ($schema_data['types'] as $schema_type_data) {
1065 $validation_errors = $this->validate_schema_requirements($post_id, $schema_type_data['type'], $schema_type_data['fields']);
1066 if (!empty($validation_errors)) {
1067 $all_validation_errors = array_merge($all_validation_errors, $validation_errors);
1068 }
1069 }
1070
1071 if (!empty($all_validation_errors)) {
1072 // Store validation errors as post meta
1073 update_post_meta($post_id, '_metasync_schema_validation_errors', $all_validation_errors);
1074
1075 // Set a transient to show admin notice immediately after save
1076 set_transient('metasync_schema_validation_error_' . $post_id, $all_validation_errors, 45);
1077 }
1078 }
1079
1080 // WordPress unslashes metadata before serialization; slash the complete
1081 // value so escaped JSON inside OTTO's persistent copy stays valid.
1082 update_post_meta($post_id, 'metasync_schema_markup', wp_slash($schema_data));
1083
1084 // Cross-plugin sync for each schema type
1085 if (!empty($schema_data['enabled']) && !empty($schema_data['types'])) {
1086 foreach ($schema_data['types'] as $schema_type_data) {
1087 $this->sync_schema_to_seo_plugins($post_id, $schema_type_data['type'], $schema_type_data['fields']);
1088 }
1089 }
1090 }
1091
1092 /**
1093 * Validate schema requirements based on type
1094 *
1095 * @param int $post_id The post ID
1096 * @param string $type Schema type
1097 * @param array $fields Schema fields
1098 * @return array Array of validation errors (empty if valid)
1099 */
1100 public function validate_schema_requirements($post_id, $type, $fields)
1101 {
1102 $errors = [];
1103
1104 switch ($type) {
1105 case 'article':
1106 $errors = $this->validate_article_schema($post_id, $fields);
1107 break;
1108 // Add more schema type validations here in the future
1109 case 'product':
1110 $errors = $this->validate_product_schema($post_id, $fields);
1111 break;
1112 case 'recipe':
1113 $errors = $this->validate_recipe_schema($post_id, $fields);
1114 break;
1115 case 'FAQPage':
1116 $errors = $this->validate_faq_schema($post_id, $fields);
1117 break;
1118 case 'Event':
1119 $errors = $this->validate_event_schema($post_id, $fields);
1120 break;
1121 case 'JobPosting':
1122 $errors = $this->validate_jobposting_schema($post_id, $fields);
1123 break;
1124 case 'Review':
1125 $errors = $this->validate_review_schema($post_id, $fields);
1126 break;
1127 case 'Course':
1128 $errors = $this->validate_course_schema($post_id, $fields);
1129 break;
1130 case 'Organization':
1131 $errors = $this->validate_organization_schema($post_id, $fields);
1132 break;
1133 case 'Person':
1134 $errors = $this->validate_person_schema($post_id, $fields);
1135 break;
1136 case 'WebSite':
1137 $errors = $this->validate_website_schema($post_id, $fields);
1138 break;
1139 case 'NewsArticle':
1140 $errors = $this->validate_newsarticle_schema($post_id, $fields);
1141 break;
1142 case 'LocalBusiness':
1143 $errors = $this->validate_local_business_schema($post_id, $fields);
1144 break;
1145 case 'HowTo':
1146 $errors = $this->validate_howto_schema($post_id, $fields);
1147 break;
1148 case 'VideoObject':
1149 $errors = $this->validate_video_object_schema($post_id, $fields);
1150 break;
1151 }
1152
1153 return $errors;
1154 }
1155
1156 /**
1157 * Validate Article schema requirements
1158 * Article requires: Headline, Description, and Image
1159 *
1160 * @param int $post_id The post ID
1161 * @param array $fields Schema fields
1162 * @return array Array of validation errors
1163 */
1164 private function validate_article_schema($post_id, $fields)
1165 {
1166 $errors = [];
1167
1168 // Get effective values (override if provided, otherwise defaults)
1169 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
1170 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
1171 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
1172
1173 // 1. Validate Headline (Title)
1174 if (empty($effective_title) || trim($effective_title) === '') {
1175 $errors[] = [
1176 'field' => 'headline',
1177 'message' => 'Article schema requires a <strong>Headline</strong>. Please add a post title or override it in the Override Default Fields section.'
1178 ];
1179 }
1180
1181 // 2. Validate Description
1182 if (empty($effective_description) || trim($effective_description) === '') {
1183 $errors[] = [
1184 'field' => 'description',
1185 'message' => 'Article schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
1186 ];
1187 }
1188
1189 // 3. Validate Image
1190 if (empty($effective_image)) {
1191 $errors[] = [
1192 'field' => 'image',
1193 '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.'
1194 ];
1195 }
1196
1197 return $errors;
1198 }
1199
1200 /**
1201 * Validate Product schema requirements
1202 * Product requires: Name, Description, Image, and Price
1203 *
1204 * @param int $post_id The post ID
1205 * @param array $fields Schema fields
1206 * @return array Array of validation errors
1207 */
1208 private function validate_product_schema($post_id, $fields)
1209 {
1210 $errors = [];
1211
1212 // Get effective values (override if provided, otherwise defaults)
1213 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
1214 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
1215 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
1216
1217 // 1. Validate Name (Title)
1218 if (empty($effective_title) || trim($effective_title) === '') {
1219 $errors[] = [
1220 'field' => 'name',
1221 'message' => 'Product schema requires a <strong>Name</strong>. Please add a post title or override it in the Override Default Fields section.'
1222 ];
1223 }
1224
1225 // 2. Validate Description
1226 if (empty($effective_description) || trim($effective_description) === '') {
1227 $errors[] = [
1228 'field' => 'description',
1229 'message' => 'Product schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
1230 ];
1231 }
1232
1233 // 3. Validate Image
1234 if (empty($effective_image)) {
1235 $errors[] = [
1236 'field' => 'image',
1237 '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.'
1238 ];
1239 }
1240
1241 // 4. Validate Price
1242 if (empty($fields['price']) || floatval($fields['price']) <= 0) {
1243 $errors[] = [
1244 'field' => 'price',
1245 'message' => 'Product schema requires a valid <strong>Price</strong>. Please enter a price greater than 0.'
1246 ];
1247 }
1248
1249 return $errors;
1250 }
1251
1252 /**
1253 * Validate Recipe schema requirements
1254 * Recipe requires: Name, Description, and Image
1255 *
1256 * @param int $post_id The post ID
1257 * @param array $fields Schema fields
1258 * @return array Array of validation errors
1259 */
1260 private function validate_recipe_schema($post_id, $fields)
1261 {
1262 $errors = [];
1263
1264 // Get effective values (override if provided, otherwise defaults)
1265 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
1266 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
1267 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
1268
1269 // 1. Validate Name (Title)
1270 if (empty($effective_title) || trim($effective_title) === '') {
1271 $errors[] = [
1272 'field' => 'name',
1273 'message' => 'Recipe schema requires a <strong>Name</strong>. Please add a post title or override it in the Override Default Fields section.'
1274 ];
1275 }
1276
1277 // 2. Validate Description
1278 if (empty($effective_description) || trim($effective_description) === '') {
1279 $errors[] = [
1280 'field' => 'description',
1281 'message' => 'Recipe schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
1282 ];
1283 }
1284
1285 // 3. Validate Image
1286 if (empty($effective_image)) {
1287 $errors[] = [
1288 'field' => 'image',
1289 '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.'
1290 ];
1291 }
1292
1293 return $errors;
1294 }
1295
1296 /**
1297 * Validate FAQ schema requirements
1298 * FAQ requires at least one Q&A pair with non-empty question and answer
1299 *
1300 * @param int $post_id The post ID
1301 * @param array $fields Schema fields
1302 * @return array Array of validation errors
1303 */
1304 private function validate_faq_schema($post_id, $fields)
1305 {
1306 $errors = [];
1307
1308 // Check if FAQ items exist
1309 if (empty($fields['faq_items']) || !is_array($fields['faq_items'])) {
1310 $errors[] = [
1311 'field' => 'faq_items',
1312 'message' => 'FAQ schema requires at least one <strong>Question & Answer</strong> pair. Please add at least one FAQ item.'
1313 ];
1314 return $errors;
1315 }
1316
1317 // Validate each FAQ item
1318 $has_valid_item = false;
1319 foreach ($fields['faq_items'] as $index => $item) {
1320 $question = isset($item['question']) ? trim($item['question']) : '';
1321 $answer = isset($item['answer']) ? trim($item['answer']) : '';
1322
1323 // Check if both question and answer are provided
1324 if (!empty($question) && !empty($answer)) {
1325 $has_valid_item = true;
1326 } elseif (!empty($question) || !empty($answer)) {
1327 // Partial entry - one is filled but not the other
1328 $item_number = $index + 1;
1329 if (empty($question)) {
1330 $errors[] = [
1331 'field' => 'faq_items',
1332 'message' => "FAQ item #{$item_number}: <strong>Question</strong> cannot be left blank."
1333 ];
1334 }
1335 if (empty($answer)) {
1336 $errors[] = [
1337 'field' => 'faq_items',
1338 'message' => "FAQ item #{$item_number}: <strong>Answer</strong> cannot be left blank."
1339 ];
1340 }
1341 }
1342 }
1343
1344 // Check if at least one valid Q&A pair exists
1345 if (!$has_valid_item) {
1346 $errors[] = [
1347 'field' => 'faq_items',
1348 '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.'
1349 ];
1350 }
1351
1352 return $errors;
1353 }
1354
1355 /**
1356 * Validate LocalBusiness schema requirements
1357 */
1358 private function validate_local_business_schema($post_id, $fields)
1359 {
1360 $errors = [];
1361
1362 if (empty($fields['business_name']) || trim($fields['business_name']) === '') {
1363 $errors[] = [
1364 'field' => 'business_name',
1365 'message' => 'LocalBusiness schema requires a <strong>Business Name</strong>.'
1366 ];
1367 }
1368
1369 if (empty($fields['street_address']) || trim($fields['street_address']) === '') {
1370 $errors[] = [
1371 'field' => 'street_address',
1372 'message' => 'LocalBusiness schema requires a <strong>Street Address</strong>.'
1373 ];
1374 }
1375
1376 return $errors;
1377 }
1378
1379 /**
1380 * Validate HowTo schema requirements
1381 */
1382 private function validate_howto_schema($post_id, $fields)
1383 {
1384 $errors = [];
1385
1386 // Validate override fields (title, description, image)
1387 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
1388 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
1389 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
1390
1391 if (empty($effective_title) || trim($effective_title) === '') {
1392 $errors[] = [
1393 'field' => 'name',
1394 'message' => 'How-To schema requires a <strong>Name</strong>. Please add a post title or override it in the Override Default Fields section.'
1395 ];
1396 }
1397
1398 if (empty($effective_description) || trim($effective_description) === '') {
1399 $errors[] = [
1400 'field' => 'description',
1401 'message' => 'How-To schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
1402 ];
1403 }
1404
1405 if (empty($effective_image)) {
1406 $errors[] = [
1407 'field' => 'image',
1408 '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.'
1409 ];
1410 }
1411
1412 // Validate at least one step with non-empty instructions
1413 $has_valid_step = false;
1414 if (!empty($fields['steps']) && is_array($fields['steps'])) {
1415 foreach ($fields['steps'] as $step) {
1416 if (!empty($step['instructions']) && trim($step['instructions']) !== '') {
1417 $has_valid_step = true;
1418 break;
1419 }
1420 }
1421 }
1422
1423 if (!$has_valid_step) {
1424 $errors[] = [
1425 'field' => 'steps',
1426 'message' => 'How-To schema requires at least one <strong>Step</strong> with instructions.'
1427 ];
1428 }
1429
1430 return $errors;
1431 }
1432
1433 /**
1434 * Validate VideoObject schema requirements
1435 */
1436 private function validate_video_object_schema($post_id, $fields)
1437 {
1438 $errors = [];
1439
1440 if (empty($fields['video_name']) || trim($fields['video_name']) === '') {
1441 $errors[] = [
1442 'field' => 'video_name',
1443 'message' => 'VideoObject schema requires a <strong>Video Name</strong>.'
1444 ];
1445 }
1446
1447 if (empty($fields['video_description']) || trim($fields['video_description']) === '') {
1448 $errors[] = [
1449 'field' => 'video_description',
1450 'message' => 'VideoObject schema requires a <strong>Video Description</strong>.'
1451 ];
1452 }
1453
1454 if (empty($fields['thumbnail_url']) || trim($fields['thumbnail_url']) === '') {
1455 $errors[] = [
1456 'field' => 'thumbnail_url',
1457 'message' => 'VideoObject schema requires a <strong>Thumbnail URL</strong>.'
1458 ];
1459 }
1460
1461 if (empty($fields['upload_date']) || trim($fields['upload_date']) === '') {
1462 $errors[] = [
1463 'field' => 'upload_date',
1464 'message' => 'VideoObject schema requires an <strong>Upload Date</strong>.'
1465 ];
1466 }
1467
1468 return $errors;
1469 }
1470
1471 /**
1472 * Display schema validation notices in admin
1473 */
1474 public function display_schema_validation_notices()
1475 {
1476 // Only show on post edit screens
1477 if ( ! function_exists( 'get_current_screen' ) ) {
1478 return;
1479 }
1480 $screen = get_current_screen();
1481 if (!$screen || ($screen->base !== 'post' && $screen->base !== 'post-new')) {
1482 return;
1483 }
1484
1485 // Get current post ID
1486 global $post;
1487 if (!$post || !isset($post->ID)) {
1488 return;
1489 }
1490
1491 $post_id = $post->ID;
1492
1493 // Check for validation errors from transient (immediately after save)
1494 $errors = get_transient('metasync_schema_validation_error_' . $post_id);
1495
1496 // If no transient, check post meta (for persistent errors)
1497 if ($errors === false) {
1498 $errors = get_post_meta($post_id, '_metasync_schema_validation_errors', true);
1499 } else {
1500 // Delete the transient after displaying once
1501 delete_transient('metasync_schema_validation_error_' . $post_id);
1502 }
1503
1504 if (empty($errors) || !is_array($errors)) {
1505 return;
1506 }
1507
1508 // Get schema type for context
1509 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
1510 $schema_type = isset($schema_data['type']) ? ucfirst($schema_data['type']) : 'Schema';
1511
1512 ?>
1513 <div class="notice notice-error is-dismissible metasync-schema-validation-notice">
1514 <h3 style="margin: 0.5em 0;">⚠️ <?php echo esc_html($schema_type); ?> Schema Validation Errors</h3>
1515 <p><strong>The following required fields are missing:</strong></p>
1516 <ul style="list-style: disc; margin-left: 20px;">
1517 <?php foreach ($errors as $error): ?>
1518 <li><?php echo wp_kses_post($error['message']); ?></li>
1519 <?php endforeach; ?>
1520 </ul>
1521 <p><em>Please fix these issues to ensure your schema markup is valid and can be properly indexed by search engines.</em></p>
1522 </div>
1523 <?php
1524 }
1525
1526 /**
1527 * Field values a schema type's field markup carries before any user input.
1528 *
1529 * render_override_fields_section() seeds a brand-new type's override inputs
1530 * with these placeholders (keys absent ⇒ placeholder), so a form save that
1531 * happens to include the type block but not its fields — the
1532 * metasync_get_schema_fields AJAX still in flight — is completed with the
1533 * same defaults, keeping a raced save identical to one that waited.
1534 *
1535 * @return array Override-field placeholder values.
1536 */
1537 private function get_unsubmitted_schema_field_defaults()
1538 {
1539 return [
1540 'title_override' => '{{post_title}}',
1541 'description_override' => '{{post_description}}',
1542 'image_override' => '{{featured_image}}',
1543 ];
1544 }
1545
1546 /**
1547 * Sanitize schema fields based on type
1548 */
1549 public function sanitize_schema_fields($fields, $type)
1550 {
1551 $sanitized = [];
1552
1553 // Sanitize override fields (common to all types that support them)
1554 // Store placeholders as-is in database, will be replaced during JSON generation
1555 $sanitized['title_override'] = isset($fields['title_override']) ? sanitize_text_field($fields['title_override']) : '';
1556 $sanitized['description_override'] = isset($fields['description_override']) ? sanitize_textarea_field($fields['description_override']) : '';
1557
1558 // For image_override, check if it's a placeholder before sanitizing as URL
1559 $image_override_raw = isset($fields['image_override']) ? trim($fields['image_override']) : '';
1560 if ($this->is_placeholder($image_override_raw)) {
1561 // Store placeholder as-is
1562 $sanitized['image_override'] = $image_override_raw;
1563 } else {
1564 // Sanitize as URL only if it's not a placeholder
1565 $sanitized['image_override'] = !empty($image_override_raw) ? esc_url_raw($image_override_raw) : '';
1566 }
1567
1568 switch ($type) {
1569 case 'article':
1570 $sanitized['organization_name'] = isset($fields['organization_name']) ? sanitize_text_field($fields['organization_name']) : '';
1571 $sanitized['organization_logo'] = isset($fields['organization_logo']) ? esc_url_raw($fields['organization_logo']) : '';
1572 break;
1573 case 'FAQPage':
1574 // FAQ doesn't use override fields, so remove them
1575 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1576 $sanitized['faq_items'] = [];
1577 if (isset($fields['faq_items']) && is_array($fields['faq_items'])) {
1578 foreach ($fields['faq_items'] as $item) {
1579 if (!empty($item['question']) && !empty($item['answer'])) {
1580 $sanitized['faq_items'][] = [
1581 'question' => sanitize_text_field($item['question']),
1582 'answer' => sanitize_textarea_field($item['answer'])
1583 ];
1584 }
1585 }
1586 }
1587 break;
1588 case 'product':
1589 $sanitized['sku'] = isset($fields['sku']) ? sanitize_text_field($fields['sku']) : '';
1590 $sanitized['brand'] = isset($fields['brand']) ? sanitize_text_field($fields['brand']) : '';
1591 $sanitized['price'] = isset($fields['price']) ? floatval($fields['price']) : 0;
1592 $sanitized['currency'] = isset($fields['currency']) ? sanitize_text_field($fields['currency']) : 'USD';
1593 $sanitized['availability'] = isset($fields['availability']) ? sanitize_text_field($fields['availability']) : 'InStock';
1594 $sanitized['condition'] = isset($fields['condition']) ? sanitize_text_field($fields['condition']) : 'NewCondition';
1595 break;
1596
1597 case 'recipe':
1598 $sanitized['yield'] = isset($fields['yield']) ? sanitize_text_field($fields['yield']) : '';
1599 $sanitized['ingredients'] = isset($fields['ingredients']) ? array_map('sanitize_text_field', array_filter($fields['ingredients'])) : [];
1600 $sanitized['instructions'] = isset($fields['instructions']) ? array_map('sanitize_textarea_field', array_filter($fields['instructions'])) : [];
1601 $sanitized['prep_time'] = isset($fields['prep_time']) ? intval($fields['prep_time']) : 0;
1602 $sanitized['cook_time'] = isset($fields['cook_time']) ? intval($fields['cook_time']) : 0;
1603 $sanitized['total_time'] = isset($fields['total_time']) ? intval($fields['total_time']) : 0;
1604 $sanitized['calories'] = isset($fields['calories']) ? intval($fields['calories']) : 0;
1605 break;
1606
1607 case 'Event':
1608 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1609 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1610 $sanitized['startDate'] = isset($fields['startDate']) ? sanitize_text_field($fields['startDate']) : '';
1611 $sanitized['endDate'] = isset($fields['endDate']) ? sanitize_text_field($fields['endDate']) : '';
1612 $sanitized['eventStatus'] = isset($fields['eventStatus']) ? sanitize_text_field($fields['eventStatus']) : 'EventScheduled';
1613 $sanitized['eventAttendanceMode'] = isset($fields['eventAttendanceMode']) ? sanitize_text_field($fields['eventAttendanceMode']) : 'OfflineEventAttendanceMode';
1614 $sanitized['location_name'] = isset($fields['location_name']) ? sanitize_text_field($fields['location_name']) : '';
1615 $sanitized['location_address'] = isset($fields['location_address']) ? sanitize_text_field($fields['location_address']) : '';
1616 $sanitized['organizer_name'] = isset($fields['organizer_name']) ? sanitize_text_field($fields['organizer_name']) : '';
1617 $sanitized['organizer_url'] = isset($fields['organizer_url']) ? esc_url_raw($fields['organizer_url']) : '';
1618 $sanitized['offer_price'] = isset($fields['offer_price']) ? sanitize_text_field($fields['offer_price']) : '';
1619 $sanitized['offer_priceCurrency'] = isset($fields['offer_priceCurrency']) ? sanitize_text_field($fields['offer_priceCurrency']) : 'USD';
1620 $sanitized['offer_availability'] = isset($fields['offer_availability']) ? sanitize_text_field($fields['offer_availability']) : 'InStock';
1621 $sanitized['offer_url'] = isset($fields['offer_url']) ? esc_url_raw($fields['offer_url']) : '';
1622 $sanitized['image'] = isset($fields['image']) ? esc_url_raw($fields['image']) : '';
1623 $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : '';
1624 break;
1625
1626 case 'JobPosting':
1627 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1628 $sanitized['title'] = isset($fields['title']) ? sanitize_text_field($fields['title']) : '';
1629 $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : '';
1630 $sanitized['datePosted'] = isset($fields['datePosted']) ? sanitize_text_field($fields['datePosted']) : '';
1631 $sanitized['validThrough'] = isset($fields['validThrough']) ? sanitize_text_field($fields['validThrough']) : '';
1632 $sanitized['employmentType'] = isset($fields['employmentType']) ? sanitize_text_field($fields['employmentType']) : 'FULL_TIME';
1633 $sanitized['hiringOrganization_name'] = isset($fields['hiringOrganization_name']) ? sanitize_text_field($fields['hiringOrganization_name']) : '';
1634 $sanitized['hiringOrganization_sameAs'] = isset($fields['hiringOrganization_sameAs']) ? esc_url_raw($fields['hiringOrganization_sameAs']) : '';
1635 $sanitized['hiringOrganization_logo'] = isset($fields['hiringOrganization_logo']) ? esc_url_raw($fields['hiringOrganization_logo']) : '';
1636 $sanitized['jobLocation_address'] = isset($fields['jobLocation_address']) ? sanitize_text_field($fields['jobLocation_address']) : '';
1637 $sanitized['baseSalary_currency'] = isset($fields['baseSalary_currency']) ? sanitize_text_field($fields['baseSalary_currency']) : '';
1638 $sanitized['baseSalary_value'] = isset($fields['baseSalary_value']) ? floatval($fields['baseSalary_value']) : 0;
1639 $sanitized['baseSalary_unitText'] = isset($fields['baseSalary_unitText']) ? sanitize_text_field($fields['baseSalary_unitText']) : 'YEAR';
1640 break;
1641
1642 case 'Review':
1643 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1644 $sanitized['itemReviewed_name'] = isset($fields['itemReviewed_name']) ? sanitize_text_field($fields['itemReviewed_name']) : '';
1645 $sanitized['reviewRating_ratingValue'] = isset($fields['reviewRating_ratingValue']) ? floatval($fields['reviewRating_ratingValue']) : 0;
1646 $sanitized['reviewRating_bestRating'] = isset($fields['reviewRating_bestRating']) ? floatval($fields['reviewRating_bestRating']) : 5;
1647 $sanitized['author_name'] = isset($fields['author_name']) ? sanitize_text_field($fields['author_name']) : '';
1648 $sanitized['reviewBody'] = isset($fields['reviewBody']) ? sanitize_textarea_field($fields['reviewBody']) : '';
1649 break;
1650
1651 case 'Course':
1652 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1653 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1654 $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : '';
1655 $sanitized['provider_name'] = isset($fields['provider_name']) ? sanitize_text_field($fields['provider_name']) : '';
1656 $sanitized['provider_sameAs'] = isset($fields['provider_sameAs']) ? esc_url_raw($fields['provider_sameAs']) : '';
1657 $sanitized['courseInstance_courseMode'] = isset($fields['courseInstance_courseMode']) ? sanitize_text_field($fields['courseInstance_courseMode']) : 'online';
1658 $sanitized['courseInstance_instructor_name'] = isset($fields['courseInstance_instructor_name']) ? sanitize_text_field($fields['courseInstance_instructor_name']) : '';
1659 break;
1660
1661 case 'Organization':
1662 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1663 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1664 $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : '';
1665 $sanitized['logo'] = isset($fields['logo']) ? esc_url_raw($fields['logo']) : '';
1666 $sanitized['contactPoint_telephone'] = isset($fields['contactPoint_telephone']) ? sanitize_text_field($fields['contactPoint_telephone']) : '';
1667 $sanitized['contactPoint_contactType'] = isset($fields['contactPoint_contactType']) ? sanitize_text_field($fields['contactPoint_contactType']) : '';
1668 $sanitized['sameAs'] = isset($fields['sameAs']) ? sanitize_textarea_field($fields['sameAs']) : '';
1669 break;
1670
1671 case 'Person':
1672 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1673 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1674 $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : '';
1675 $sanitized['jobTitle'] = isset($fields['jobTitle']) ? sanitize_text_field($fields['jobTitle']) : '';
1676 $sanitized['worksFor'] = isset($fields['worksFor']) ? sanitize_text_field($fields['worksFor']) : '';
1677 $sanitized['email'] = isset($fields['email']) ? sanitize_email($fields['email']) : '';
1678 $sanitized['image'] = isset($fields['image']) ? esc_url_raw($fields['image']) : '';
1679 $sanitized['sameAs'] = isset($fields['sameAs']) ? sanitize_textarea_field($fields['sameAs']) : '';
1680 break;
1681
1682 case 'WebSite':
1683 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1684 $sanitized['name'] = isset($fields['name']) ? sanitize_text_field($fields['name']) : '';
1685 $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : '';
1686 $sanitized['searchbox_enabled'] = isset($fields['searchbox_enabled']) ? (bool) $fields['searchbox_enabled'] : false;
1687 $sanitized['searchbox_query_input'] = isset($fields['searchbox_query_input']) ? sanitize_text_field($fields['searchbox_query_input']) : '';
1688 break;
1689
1690 case 'NewsArticle':
1691 $sanitized['organization_name'] = isset($fields['organization_name']) ? sanitize_text_field($fields['organization_name']) : '';
1692 $sanitized['organization_logo'] = isset($fields['organization_logo']) ? esc_url_raw($fields['organization_logo']) : '';
1693 break;
1694
1695 case 'LocalBusiness':
1696 // Self-managed type, remove override fields
1697 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1698 $sanitized['business_name'] = isset($fields['business_name']) ? sanitize_text_field($fields['business_name']) : '';
1699 $sanitized['street_address'] = isset($fields['street_address']) ? sanitize_text_field($fields['street_address']) : '';
1700 $sanitized['description'] = isset($fields['description']) ? sanitize_textarea_field($fields['description']) : '';
1701 $sanitized['url'] = isset($fields['url']) ? esc_url_raw($fields['url']) : '';
1702 $sanitized['telephone'] = isset($fields['telephone']) ? sanitize_text_field($fields['telephone']) : '';
1703 $sanitized['price_range'] = isset($fields['price_range']) ? sanitize_text_field($fields['price_range']) : '';
1704 $sanitized['image'] = isset($fields['image']) ? esc_url_raw($fields['image']) : '';
1705 $sanitized['city'] = isset($fields['city']) ? sanitize_text_field($fields['city']) : '';
1706 $sanitized['state'] = isset($fields['state']) ? sanitize_text_field($fields['state']) : '';
1707 $sanitized['postal_code'] = isset($fields['postal_code']) ? sanitize_text_field($fields['postal_code']) : '';
1708 $sanitized['country'] = isset($fields['country']) ? sanitize_text_field($fields['country']) : '';
1709 $sanitized['latitude'] = isset($fields['latitude']) ? sanitize_text_field($fields['latitude']) : '';
1710 $sanitized['longitude'] = isset($fields['longitude']) ? sanitize_text_field($fields['longitude']) : '';
1711 $sanitized['opening_hours'] = [];
1712 if (isset($fields['opening_hours']) && is_array($fields['opening_hours'])) {
1713 foreach ($fields['opening_hours'] as $hours) {
1714 if (!empty($hours['day'])) {
1715 $sanitized['opening_hours'][] = [
1716 'day' => sanitize_text_field($hours['day']),
1717 'open' => sanitize_text_field($hours['open']),
1718 'close' => sanitize_text_field($hours['close']),
1719 ];
1720 }
1721 }
1722 }
1723 break;
1724
1725 case 'HowTo':
1726 $sanitized['total_time'] = isset($fields['total_time']) ? intval($fields['total_time']) : 0;
1727 $sanitized['estimated_cost'] = isset($fields['estimated_cost']) ? sanitize_text_field($fields['estimated_cost']) : '';
1728 $sanitized['supplies'] = isset($fields['supplies']) ? array_map('sanitize_text_field', array_filter($fields['supplies'])) : [];
1729 $sanitized['tools'] = isset($fields['tools']) ? array_map('sanitize_text_field', array_filter($fields['tools'])) : [];
1730 $sanitized['steps'] = [];
1731 if (isset($fields['steps']) && is_array($fields['steps'])) {
1732 foreach ($fields['steps'] as $step) {
1733 $sanitized['steps'][] = [
1734 'instructions' => isset($step['instructions']) ? sanitize_textarea_field($step['instructions']) : '',
1735 'image' => isset($step['image']) ? esc_url_raw($step['image']) : '',
1736 ];
1737 }
1738 }
1739 break;
1740
1741 case 'VideoObject':
1742 // Self-managed type, remove override fields
1743 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1744 $sanitized['video_name'] = isset($fields['video_name']) ? sanitize_text_field($fields['video_name']) : '';
1745 $sanitized['video_description'] = isset($fields['video_description']) ? sanitize_textarea_field($fields['video_description']) : '';
1746 $sanitized['thumbnail_url'] = isset($fields['thumbnail_url']) ? esc_url_raw($fields['thumbnail_url']) : '';
1747 $sanitized['upload_date'] = isset($fields['upload_date']) ? sanitize_text_field($fields['upload_date']) : '';
1748 $sanitized['content_url'] = isset($fields['content_url']) ? esc_url_raw($fields['content_url']) : '';
1749 $sanitized['embed_url'] = isset($fields['embed_url']) ? esc_url_raw($fields['embed_url']) : '';
1750 $sanitized['duration'] = isset($fields['duration']) ? intval($fields['duration']) : 0;
1751 break;
1752 }
1753
1754 return $sanitized;
1755 }
1756
1757 /**
1758 * Output schema markup to the frontend
1759 */
1760 public function output_schema_markup()
1761 {
1762 // Schema Markup switched off. This covers every JSON-LD source resolved
1763 // below — global Organization/WebSite nodes, the per-post meta box types,
1764 // live OTTO structured data and the DB-persisted OTTO payload.
1765 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
1766 return;
1767 }
1768
1769 $all_json_ld = [];
1770
1771 // Holds the DB-persisted OTTO JSON-LD string when OTTO is off (output as its
1772 // own tag below). Stays null while OTTO is live to avoid the duplicate.
1773 $otto_persisted_jsonld = null;
1774
1775 // ---- Global schema: WebSite on front page, Organization on all pages ----
1776 $metasync_options = get_option('metasync_options', []);
1777 $schema_settings = isset($metasync_options['schema']) ? $metasync_options['schema'] : [];
1778
1779 // Organization schema (site-wide)
1780 if (!empty($schema_settings['org_name'])) {
1781 if ($this->should_output_schema_type('Organization')) {
1782 $org_node = ['@type' => 'Organization'];
1783 $org_node['name'] = $schema_settings['org_name'];
1784 if (!empty($schema_settings['org_url'])) {
1785 $org_node['url'] = $schema_settings['org_url'];
1786 }
1787 if (!empty($schema_settings['org_logo'])) {
1788 $org_node['logo'] = [
1789 '@type' => 'ImageObject',
1790 'url' => $schema_settings['org_logo'],
1791 ];
1792 }
1793 if (!empty($schema_settings['org_contact_telephone']) || !empty($schema_settings['org_contact_type'])) {
1794 $org_node['contactPoint'] = ['@type' => 'ContactPoint'];
1795 if (!empty($schema_settings['org_contact_telephone'])) {
1796 $org_node['contactPoint']['telephone'] = $schema_settings['org_contact_telephone'];
1797 }
1798 if (!empty($schema_settings['org_contact_type'])) {
1799 $org_node['contactPoint']['contactType'] = $schema_settings['org_contact_type'];
1800 }
1801 }
1802 if (!empty($schema_settings['org_same_as'])) {
1803 $urls = array_filter(array_map('trim', explode("\n", $schema_settings['org_same_as'])));
1804 if (!empty($urls)) {
1805 $org_node['sameAs'] = $urls;
1806 }
1807 }
1808 $all_json_ld[] = $org_node;
1809 }
1810 }
1811
1812 // WebSite schema (front page only)
1813 if (is_front_page()) {
1814 if (!empty($schema_settings['website_name']) && $this->should_output_schema_type('WebSite')) {
1815 $ws_node = ['@type' => 'WebSite'];
1816 $ws_node['name'] = $schema_settings['website_name'];
1817 $ws_node['url'] = !empty($schema_settings['website_url']) ? $schema_settings['website_url'] : home_url('/');
1818 if (!empty($schema_settings['website_searchbox'])) {
1819 $base_url = rtrim($ws_node['url'], '/');
1820 $ws_node['potentialAction'] = [
1821 '@type' => 'SearchAction',
1822 'target' => [
1823 '@type' => 'EntryPoint',
1824 'urlTemplate' => $base_url . '/?s={search_term_string}',
1825 ],
1826 'query-input' => 'required name=search_term_string',
1827 ];
1828 }
1829 $all_json_ld[] = $ws_node;
1830 }
1831 }
1832
1833 // Person schema on author archive pages
1834 if (is_author()) {
1835 if ($this->should_output_schema_type('Person')) {
1836 $author = get_queried_object();
1837 if ($author && is_a($author, 'WP_User')) {
1838 $person_node = [
1839 '@type' => 'Person',
1840 'name' => $author->display_name,
1841 'url' => get_author_posts_url($author->ID),
1842 ];
1843 $user_desc = get_the_author_meta('description', $author->ID);
1844 if (!empty($user_desc)) {
1845 $person_node['description'] = $user_desc;
1846 }
1847 $all_json_ld[] = $person_node;
1848 }
1849 }
1850 }
1851
1852 // ---- Per-post schema (singular pages only) ----
1853 if (is_singular()) {
1854 global $post;
1855 if ($post && isset($post->ID)) {
1856 $schema_data = get_post_meta($post->ID, 'metasync_schema_markup', true);
1857
1858 $has_user_types = !empty($schema_data['enabled']) && !empty($schema_data['types']);
1859
1860 // Track which types the global schema already outputs
1861 $global_types_output = [];
1862 if (!empty($schema_settings['org_name'])) {
1863 $global_types_output[] = 'Organization';
1864 }
1865 if (is_front_page() && !empty($schema_settings['website_name'])) {
1866 $global_types_output[] = 'WebSite';
1867 }
1868
1869 // Generate JSON-LD for user-entered schema types.
1870 if ($has_user_types) {
1871 $validation_errors = get_post_meta($post->ID, '_metasync_schema_validation_errors', true);
1872 if (empty($validation_errors) || !is_array($validation_errors)) {
1873 foreach ($schema_data['types'] as $schema_type_data) {
1874 // Skip per-post types that the global schema already covers
1875 $type_key = $schema_type_data['type'];
1876 if (in_array($type_key, $global_types_output, true)) {
1877 continue;
1878 }
1879
1880 // Skip BreadcrumbList if already injected by the breadcrumbs module.
1881 if (
1882 $type_key === 'BreadcrumbList'
1883 && class_exists('Metasync_Breadcrumbs_Schema')
1884 && Metasync_Breadcrumbs_Schema::$breadcrumb_list_injected
1885 ) {
1886 continue;
1887 }
1888
1889 if ($this->should_output_schema_type($type_key)) {
1890 $json_ld = $this->generate_json_ld($post, $schema_type_data);
1891 if ($json_ld) {
1892 $all_json_ld[] = $json_ld;
1893 }
1894 }
1895 }
1896 }
1897 }
1898
1899 // OTTO-persisted JSON-LD: skip only while OTTO is live — avoid
1900 // duplicate; the persisted copy is output below when OTTO is off.
1901 //
1902 // While OTTO is enabled, schema is delivered live via `header_html_insertion`
1903 // and injected by Otto_html_class with a `data-otto="true"` marker, so merging
1904 // the DB-persisted copy into this @graph would produce duplicate JSON-LD on
1905 // every page. When OTTO is OFF there is no live injection and therefore
1906 // no duplicate risk, yet the schema is still saved in the DB — so we surface the
1907 // persisted copy as its own standalone tag after the @graph block.
1908 if (
1909 isset($schema_data['otto_jsonld']['data'])
1910 && is_string($schema_data['otto_jsonld']['data'])
1911 && $schema_data['otto_jsonld']['data'] !== ''
1912 && (!class_exists('Metasync_Otto_Config') || !Metasync_Otto_Config::is_otto_enabled())
1913 && !$this->active_seo_plugin_emits_schema()
1914 ) {
1915 $otto_persisted_jsonld = $schema_data['otto_jsonld']['data'];
1916 }
1917 }
1918 }
1919
1920 // Output all schemas in a single script tag with @graph pattern
1921 if (!empty($all_json_ld)) {
1922 $final_json_ld = [
1923 '@context' => 'https://schema.org',
1924 '@graph' => $all_json_ld,
1925 ];
1926
1927 $graph_json = metasync_safe_json_ld_encode($final_json_ld, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
1928 if (is_string($graph_json) && $graph_json !== '') {
1929 echo '<script type="application/ld+json" class="metasync-schema">' . "\n";
1930 // 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.
1931 echo $graph_json;
1932 echo "\n" . '</script>' . "\n";
1933 }
1934 }
1935
1936 // OTTO is off: output the DB-persisted OTTO JSON-LD as its own standalone tag.
1937 // Echoed without a json_decode/encode round-trip to match how OTTO injects it live
1938 // and to avoid the Unicode-corruption noted in. The only transform applied is
1939 // escaping `</` to `<\/` so a literal `</script>` inside a string value cannot break
1940 // out of the tag (page corruption / XSS). `\/` is a valid JSON escape for `/`, so the
1941 // consumer reads identical content and the persisted bytes are otherwise preserved.
1942 // The data field is a full JSON-LD document or node, so it is emitted as a separate
1943 // script rather than merged into the @graph above. Stays null while OTTO is live.
1944 //
1945 // NOTE (per-page scope): the OTTO gate above uses Metasync_Otto_Config::is_otto_enabled(),
1946 // which is global (UUID configured) rather than per-request. On a site where OTTO is
1947 // enabled globally but did not inject on this specific page (e.g. excluded/uncrawled URL),
1948 // the persisted copy is intentionally still skipped — OTTO owns schema delivery while it
1949 // is enabled, and emitting here cannot reliably know whether OTTO injected this request
1950 // without risking the duplicate. Tracked as a known limitation.
1951 if (!empty($otto_persisted_jsonld)) {
1952 echo '<script type="application/ld+json" class="metasync-schema metasync-otto-schema" data-otto="true">' . "\n";
1953 // Escape `</` -> `<\/` to prevent `</script>` breakout; valid JSON, content unchanged.
1954 echo metasync_escape_json_ld_for_script($otto_persisted_jsonld); // phpcs:ignore WordPress.Security.EscapeOutput -- persisted JSON-LD string, only `</` escaped for safe embedding
1955 echo "\n" . '</script>' . "\n";
1956 }
1957 }
1958
1959 /**
1960 * (AC#3): Whether an active third-party SEO plugin will already emit the schema.
1961 *
1962 * When OTTO syncs, it cross-writes the structured data into the active SEO plugin
1963 * (Yoast / Rank Math / AIOSEO), which then renders it from its own storage. If such a
1964 * plugin is active, also emitting our persisted copy would duplicate the JSON-LD — so the
1965 * caller suppresses the standalone OTTO tag and lets the SEO plugin own the output.
1966 *
1967 * Returns false when the conflict handler is unavailable (no third-party integration in
1968 * play), preserving the OTTO-off / no-SEO-plugin path that this feature exists for.
1969 *
1970 * @return bool
1971 */
1972 protected function active_seo_plugin_emits_schema() {
1973 if (!class_exists('Metasync_Seo_Conflict_Handler')) {
1974 return false;
1975 }
1976 return Metasync_Seo_Conflict_Handler::get_instance()->has_active_seo_plugin();
1977 }
1978
1979 /**
1980 * Replace placeholders with actual dynamic values
1981 *
1982 * @param string $value Value that may contain placeholders
1983 * @param int $post_id Post ID
1984 * @return string Value with placeholders replaced
1985 */
1986 private function replace_placeholders($value, $post_id)
1987 {
1988 if (empty($value)) {
1989 return $value;
1990 }
1991
1992 $defaults = $this->get_default_schema_values($post_id);
1993
1994 // Replace placeholders with actual values
1995 $value = str_replace('{{post_title}}', $defaults['title'], $value);
1996 $value = str_replace('{{post_description}}', $defaults['description'], $value);
1997 $value = str_replace('{{featured_image}}', $defaults['image'], $value);
1998
1999 return $value;
2000 }
2001
2002 /**
2003 * Check if a value is a placeholder (should be treated as empty/default)
2004 *
2005 * @param string $value Value to check
2006 * @return bool True if value is a placeholder
2007 */
2008 private function is_placeholder($value)
2009 {
2010 $placeholders = ['{{post_title}}', '{{post_description}}', '{{featured_image}}'];
2011 return in_array(trim($value), $placeholders, true);
2012 }
2013
2014 /**
2015 * Get effective schema value (override if provided, otherwise default)
2016 * Replaces placeholders with actual dynamic values during JSON generation
2017 *
2018 * @param string $field_name Field name (title_override, description_override, image_override)
2019 * @param array $fields Schema fields array
2020 * @param int $post_id Post ID
2021 * @return string Effective value with placeholders replaced
2022 */
2023 private function get_effective_schema_value($field_name, $fields, $post_id)
2024 {
2025 // Get override value from database
2026 $override_key = $field_name;
2027 $override_value = isset($fields[$override_key]) ? trim($fields[$override_key]) : '';
2028
2029 // If override value is empty (blank field), return empty
2030 // This will trigger validation error - user must either fill it or use placeholder
2031 if (empty($override_value)) {
2032 return '';
2033 }
2034
2035 // If override value is provided (including placeholders), replace placeholders with actual data
2036 return $this->replace_placeholders($override_value, $post_id);
2037 }
2038
2039 /**
2040 * Generate JSON-LD based on schema type
2041 */
2042 private function generate_json_ld($post, $schema_type_data)
2043 {
2044 $type = $schema_type_data['type'];
2045 $fields = $schema_type_data['fields'];
2046
2047 // Build base JSON-LD without @context (will be added at root level)
2048 // Map type keys to proper Schema.org @type values
2049 $type_map = [
2050 'article' => 'Article',
2051 'FAQPage' => 'FAQPage',
2052 'product' => 'Product',
2053 'recipe' => 'Recipe',
2054 'Event' => 'Event',
2055 'JobPosting' => 'JobPosting',
2056 'Review' => 'Review',
2057 'Course' => 'Course',
2058 'Organization' => 'Organization',
2059 'Person' => 'Person',
2060 'WebSite' => 'WebSite',
2061 'NewsArticle' => 'NewsArticle',
2062 'LocalBusiness' => 'LocalBusiness',
2063 'HowTo' => 'HowTo',
2064 'VideoObject' => 'VideoObject',
2065 ];
2066 $json_ld = [
2067 '@type' => isset($type_map[$type]) ? $type_map[$type] : ucfirst($type)
2068 ];
2069
2070 // Types that manage their own fields (no override fields pattern)
2071 $self_managed_types = ['Event', 'JobPosting', 'Review', 'Course', 'Organization', 'Person', 'WebSite', 'LocalBusiness', 'VideoObject'];
2072
2073 if (!in_array($type, $self_managed_types)) {
2074 // Get effective values (override if provided, otherwise defaults)
2075 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post->ID);
2076 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post->ID);
2077 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post->ID);
2078
2079 // For articles/NewsArticle, don't add 'name' and 'url' as we'll use 'headline' and 'mainEntityOfPage' instead
2080 // For FAQ, we only need the schema type and mainEntity
2081 if ($type !== 'article' && $type !== 'FAQPage' && $type !== 'NewsArticle') {
2082 $json_ld['name'] = $effective_title;
2083 $json_ld['url'] = get_permalink($post->ID);
2084 $json_ld['description'] = $effective_description;
2085
2086 // Add images - use override if provided, otherwise get from featured image
2087 if (!empty($effective_image)) {
2088 $json_ld['image'] = $effective_image;
2089 } else {
2090 $images = $this->get_post_images($post->ID);
2091 if (!empty($images)) {
2092 $json_ld['image'] = count($images) === 1 ? $images[0] : $images;
2093 }
2094 }
2095 } elseif ($type === 'article' || $type === 'NewsArticle') {
2096 $json_ld['description'] = $effective_description;
2097
2098 // Add images - use override if provided, otherwise get from featured image
2099 if (!empty($effective_image)) {
2100 $json_ld['image'] = $effective_image;
2101 } else {
2102 $images = $this->get_post_images($post->ID);
2103 if (!empty($images)) {
2104 $json_ld['image'] = count($images) === 1 ? $images[0] : $images;
2105 }
2106 }
2107 }
2108 }
2109
2110 switch ($type) {
2111 case 'article':
2112 $json_ld = array_merge($json_ld, $this->generate_article_json_ld($fields, $post));
2113 break;
2114 case 'FAQPage':
2115 $json_ld = array_merge($json_ld, $this->generate_faq_json_ld($fields));
2116 break;
2117 case 'product':
2118 $json_ld = array_merge($json_ld, $this->generate_product_json_ld($fields));
2119 break;
2120 case 'recipe':
2121 $json_ld = array_merge($json_ld, $this->generate_recipe_json_ld($fields));
2122 break;
2123 case 'Event':
2124 $json_ld = array_merge($json_ld, $this->generate_event_json_ld($fields, $post));
2125 break;
2126 case 'JobPosting':
2127 $json_ld = array_merge($json_ld, $this->generate_jobposting_json_ld($fields, $post));
2128 break;
2129 case 'Review':
2130 $json_ld = array_merge($json_ld, $this->generate_review_json_ld($fields, $post));
2131 break;
2132 case 'Course':
2133 $json_ld = array_merge($json_ld, $this->generate_course_json_ld($fields, $post));
2134 break;
2135 case 'Organization':
2136 $json_ld = array_merge($json_ld, $this->generate_organization_json_ld($fields, $post));
2137 break;
2138 case 'Person':
2139 $json_ld = array_merge($json_ld, $this->generate_person_json_ld($fields, $post));
2140 break;
2141 case 'WebSite':
2142 $json_ld = array_merge($json_ld, $this->generate_website_json_ld($fields, $post));
2143 break;
2144 case 'NewsArticle':
2145 $json_ld = array_merge($json_ld, $this->generate_newsarticle_json_ld($fields, $post));
2146 break;
2147 case 'LocalBusiness':
2148 $local_business_data = $this->generate_local_business_json_ld($fields);
2149 if ($local_business_data === false) {
2150 return null;
2151 }
2152 $json_ld = array_merge($json_ld, $local_business_data);
2153 break;
2154 case 'HowTo':
2155 $json_ld = array_merge($json_ld, $this->generate_howto_json_ld($fields));
2156 break;
2157 case 'VideoObject':
2158 $json_ld = array_merge($json_ld, $this->generate_video_object_json_ld($fields));
2159 break;
2160 }
2161
2162 return $json_ld;
2163 }
2164
2165 /**
2166 * Generate Article JSON-LD
2167 */
2168 private function generate_article_json_ld($fields, $post)
2169 {
2170 $article_data = [];
2171
2172 // Add headline (required by Google) - use override if provided
2173 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post->ID);
2174 $article_data['headline'] = $effective_title;
2175
2176 // Add mainEntityOfPage instead of url (Google's standard for articles)
2177 $article_data['mainEntityOfPage'] = [
2178 '@type' => 'WebPage',
2179 '@id' => get_permalink($post->ID)
2180 ];
2181
2182 // Add datePublished (required by Google)
2183 $article_data['datePublished'] = get_the_date('c', $post->ID);
2184
2185 // Add dateModified (required by Google)
2186 $article_data['dateModified'] = get_the_modified_date('c', $post->ID);
2187
2188 // Add author information
2189 if (!empty($fields['organization_name'])) {
2190 // Organization as author
2191 $article_data['author'] = [
2192 '@type' => 'Organization',
2193 'name' => $fields['organization_name']
2194 ];
2195
2196 // Add organization logo if provided
2197 if (!empty($fields['organization_logo'])) {
2198 $article_data['author']['logo'] = [
2199 '@type' => 'ImageObject',
2200 'url' => $fields['organization_logo']
2201 ];
2202 }
2203 } else {
2204 // Fallback to WordPress post author
2205 $author_id = $post->post_author;
2206 $author_name = get_the_author_meta('display_name', $author_id);
2207 $author_url = get_author_posts_url($author_id);
2208
2209 $article_data['author'] = [
2210 '@type' => 'Person',
2211 'name' => $author_name,
2212 'url' => $author_url
2213 ];
2214 }
2215
2216 // Add publisher information (required by Google)
2217 if (!empty($fields['organization_name'])) {
2218 $article_data['publisher'] = [
2219 '@type' => 'Organization',
2220 'name' => $fields['organization_name']
2221 ];
2222
2223 // Add organization logo if provided (required for publisher)
2224 if (!empty($fields['organization_logo'])) {
2225 $article_data['publisher']['logo'] = [
2226 '@type' => 'ImageObject',
2227 'url' => $fields['organization_logo']
2228 ];
2229 }
2230 }
2231
2232 return $article_data;
2233 }
2234
2235 /**
2236 * Generate FAQ JSON-LD
2237 */
2238 private function generate_faq_json_ld($fields)
2239 {
2240 $faq_data = [];
2241
2242 // Add FAQ mainEntity
2243 if (!empty($fields['faq_items'])) {
2244 $faq_data['mainEntity'] = [];
2245
2246 foreach ($fields['faq_items'] as $item) {
2247 $faq_data['mainEntity'][] = [
2248 '@type' => 'Question',
2249 'name' => $item['question'],
2250 'acceptedAnswer' => [
2251 '@type' => 'Answer',
2252 'text' => $item['answer']
2253 ]
2254 ];
2255 }
2256 }
2257
2258 return $faq_data;
2259 }
2260
2261 /**
2262 * Generate Product JSON-LD
2263 */
2264 private function generate_product_json_ld($fields)
2265 {
2266 $metasync_options = get_option('metasync_options', []);
2267 $override_wc = !empty($metasync_options['schema']['override_woocommerce_schema']);
2268 $wc_active = class_exists('WooCommerce');
2269
2270 // When WooCommerce is active and override is OFF, suppress MetaSync Product
2271 // schema entirely — let WooCommerce handle its own Product structured data.
2272 if ($wc_active && !$override_wc) {
2273 return [];
2274 }
2275
2276 $product_data = [];
2277
2278 if (!empty($fields['sku'])) {
2279 $product_data['sku'] = $fields['sku'];
2280 }
2281
2282 if (!empty($fields['brand'])) {
2283 $product_data['brand'] = [
2284 '@type' => 'Brand',
2285 'name' => $fields['brand']
2286 ];
2287 }
2288
2289 if (!empty($fields['price'])) {
2290 $product_data['offers'] = [
2291 '@type' => 'Offer',
2292 'price' => $fields['price'],
2293 'priceCurrency' => $fields['currency'] ?? 'USD',
2294 'availability' => 'https://schema.org/' . ($fields['availability'] ?? 'InStock'),
2295 'itemCondition' => 'https://schema.org/' . ($fields['condition'] ?? 'NewCondition')
2296 ];
2297 }
2298
2299 // When WooCommerce is active AND override is ON, enrich with WC product data
2300 if ($wc_active && $override_wc && function_exists('wc_get_product')) {
2301 global $post;
2302 $wc_post_id = isset($post->ID) ? $post->ID : 0;
2303 $product = wc_get_product($wc_post_id);
2304 if ($product && is_a($product, 'WC_Product')) {
2305 // Auto-fill price from WC (overwrites manual fields with live data)
2306 $wc_price = $product->get_price();
2307 if (!empty($wc_price)) {
2308 if (!isset($product_data['offers'])) {
2309 $product_data['offers'] = ['@type' => 'Offer'];
2310 }
2311 $product_data['offers']['price'] = $wc_price;
2312 $product_data['offers']['priceCurrency'] = get_woocommerce_currency();
2313 $product_data['offers']['availability'] = 'https://schema.org/' . ($product->is_in_stock() ? 'InStock' : 'OutOfStock');
2314 }
2315
2316 // Auto-fill SKU
2317 $wc_sku = $product->get_sku();
2318 if (!empty($wc_sku)) {
2319 $product_data['sku'] = $wc_sku;
2320 }
2321
2322 // AggregateRating from WooCommerce reviews
2323 $review_count = $product->get_review_count();
2324 if ($review_count > 0) {
2325 $product_data['aggregateRating'] = [
2326 '@type' => 'AggregateRating',
2327 'ratingValue' => $product->get_average_rating(),
2328 'reviewCount' => $review_count,
2329 ];
2330 }
2331 }
2332 }
2333
2334 return $product_data;
2335 }
2336
2337 /**
2338 * Generate Recipe JSON-LD
2339 */
2340 private function generate_recipe_json_ld($fields)
2341 {
2342 $recipe_data = [];
2343
2344 if (!empty($fields['yield'])) {
2345 $recipe_data['recipeYield'] = $fields['yield'];
2346 }
2347
2348 if (!empty($fields['ingredients'])) {
2349 $recipe_data['recipeIngredient'] = $fields['ingredients'];
2350 }
2351
2352 if (!empty($fields['instructions'])) {
2353 $recipe_data['recipeInstructions'] = [];
2354 foreach ($fields['instructions'] as $instruction) {
2355 $recipe_data['recipeInstructions'][] = [
2356 '@type' => 'HowToStep',
2357 'text' => $instruction
2358 ];
2359 }
2360 }
2361
2362 if (!empty($fields['prep_time'])) {
2363 $recipe_data['prepTime'] = 'PT' . $fields['prep_time'] . 'M';
2364 }
2365
2366 if (!empty($fields['cook_time'])) {
2367 $recipe_data['cookTime'] = 'PT' . $fields['cook_time'] . 'M';
2368 }
2369
2370 if (!empty($fields['total_time'])) {
2371 $recipe_data['totalTime'] = 'PT' . $fields['total_time'] . 'M';
2372 }
2373
2374 if (!empty($fields['calories'])) {
2375 $recipe_data['nutrition'] = [
2376 '@type' => 'NutritionInformation',
2377 'calories' => $fields['calories'] . ' calories'
2378 ];
2379 }
2380
2381 return $recipe_data;
2382 }
2383
2384 // ======================================================================
2385 // New Schema Type: Event
2386 // ======================================================================
2387
2388 /**
2389 * Render Event schema fields
2390 */
2391 private function render_event_fields($fields, $index = 0)
2392 {
2393 $name = isset($fields['name']) ? $fields['name'] : '';
2394 $startDate = isset($fields['startDate']) ? $fields['startDate'] : '';
2395 $endDate = isset($fields['endDate']) ? $fields['endDate'] : '';
2396 $eventStatus = isset($fields['eventStatus']) ? $fields['eventStatus'] : 'EventScheduled';
2397 $eventAttendanceMode = isset($fields['eventAttendanceMode']) ? $fields['eventAttendanceMode'] : 'OfflineEventAttendanceMode';
2398 $location_name = isset($fields['location_name']) ? $fields['location_name'] : '';
2399 $location_address = isset($fields['location_address']) ? $fields['location_address'] : '';
2400 $organizer_name = isset($fields['organizer_name']) ? $fields['organizer_name'] : '';
2401 $organizer_url = isset($fields['organizer_url']) ? $fields['organizer_url'] : '';
2402 $offer_price = isset($fields['offer_price']) ? $fields['offer_price'] : '';
2403 $offer_priceCurrency = isset($fields['offer_priceCurrency']) ? $fields['offer_priceCurrency'] : 'USD';
2404 $offer_availability = isset($fields['offer_availability']) ? $fields['offer_availability'] : 'InStock';
2405 $offer_url = isset($fields['offer_url']) ? $fields['offer_url'] : '';
2406 $image = isset($fields['image']) ? $fields['image'] : '';
2407 $description = isset($fields['description']) ? $fields['description'] : '';
2408
2409 ?>
2410 <div class="schema-field-group">
2411 <h4>Event Information</h4>
2412 <p class="description" style="margin-top: 0;">Fields marked with * are required.</p>
2413
2414 <div class="schema-field">
2415 <label for="event_name_<?php echo esc_attr((string) $index); ?>">Event Name: <span style="color: #dc3232;">*</span></label>
2416 <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">
2417 </div>
2418
2419 <div class="schema-field">
2420 <label for="event_startDate_<?php echo esc_attr((string) $index); ?>">Start Date: <span style="color: #dc3232;">*</span></label>
2421 <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); ?>">
2422 </div>
2423
2424 <div class="schema-field">
2425 <label for="event_endDate_<?php echo esc_attr((string) $index); ?>">End Date:</label>
2426 <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); ?>">
2427 </div>
2428
2429 <div class="schema-field">
2430 <label for="event_status_<?php echo esc_attr((string) $index); ?>">Event Status:</label>
2431 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][eventStatus]" id="event_status_<?php echo esc_attr((string) $index); ?>">
2432 <option value="EventScheduled" <?php selected($eventStatus, 'EventScheduled'); ?>>Scheduled</option>
2433 <option value="EventCancelled" <?php selected($eventStatus, 'EventCancelled'); ?>>Cancelled</option>
2434 <option value="EventPostponed" <?php selected($eventStatus, 'EventPostponed'); ?>>Postponed</option>
2435 <option value="EventRescheduled" <?php selected($eventStatus, 'EventRescheduled'); ?>>Rescheduled</option>
2436 </select>
2437 </div>
2438
2439 <div class="schema-field">
2440 <label for="event_attendance_<?php echo esc_attr((string) $index); ?>">Attendance Mode:</label>
2441 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][eventAttendanceMode]" id="event_attendance_<?php echo esc_attr((string) $index); ?>">
2442 <option value="OfflineEventAttendanceMode" <?php selected($eventAttendanceMode, 'OfflineEventAttendanceMode'); ?>>In-Person</option>
2443 <option value="OnlineEventAttendanceMode" <?php selected($eventAttendanceMode, 'OnlineEventAttendanceMode'); ?>>Online</option>
2444 <option value="MixedEventAttendanceMode" <?php selected($eventAttendanceMode, 'MixedEventAttendanceMode'); ?>>Mixed</option>
2445 </select>
2446 </div>
2447
2448 <div class="schema-field">
2449 <label for="event_location_name_<?php echo esc_attr((string) $index); ?>">Location Name:</label>
2450 <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">
2451 </div>
2452
2453 <div class="schema-field">
2454 <label for="event_location_address_<?php echo esc_attr((string) $index); ?>">Location Address:</label>
2455 <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">
2456 </div>
2457
2458 <div class="schema-field">
2459 <label for="event_organizer_name_<?php echo esc_attr((string) $index); ?>">Organizer Name:</label>
2460 <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">
2461 </div>
2462
2463 <div class="schema-field">
2464 <label for="event_organizer_url_<?php echo esc_attr((string) $index); ?>">Organizer URL:</label>
2465 <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">
2466 </div>
2467
2468 <div class="schema-field">
2469 <label for="event_offer_price_<?php echo esc_attr((string) $index); ?>">Ticket Price:</label>
2470 <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">
2471 </div>
2472
2473 <div class="schema-field">
2474 <label for="event_offer_currency_<?php echo esc_attr((string) $index); ?>">Price Currency:</label>
2475 <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">
2476 </div>
2477
2478 <div class="schema-field">
2479 <label for="event_offer_availability_<?php echo esc_attr((string) $index); ?>">Availability:</label>
2480 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][offer_availability]" id="event_offer_availability_<?php echo esc_attr((string) $index); ?>">
2481 <option value="InStock" <?php selected($offer_availability, 'InStock'); ?>>In Stock</option>
2482 <option value="SoldOut" <?php selected($offer_availability, 'SoldOut'); ?>>Sold Out</option>
2483 <option value="PreOrder" <?php selected($offer_availability, 'PreOrder'); ?>>Pre-Order</option>
2484 <option value="LimitedAvailability" <?php selected($offer_availability, 'LimitedAvailability'); ?>>Limited Availability</option>
2485 </select>
2486 </div>
2487
2488 <div class="schema-field">
2489 <label for="event_offer_url_<?php echo esc_attr((string) $index); ?>">Ticket URL:</label>
2490 <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">
2491 </div>
2492
2493 <div class="schema-field">
2494 <label for="event_image_<?php echo esc_attr((string) $index); ?>">Image URL:</label>
2495 <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">
2496 </div>
2497
2498 <div class="schema-field">
2499 <label for="event_description_<?php echo esc_attr((string) $index); ?>">Description:</label>
2500 <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>
2501 </div>
2502 </div>
2503 <?php
2504 }
2505
2506 /**
2507 * Validate Event schema
2508 */
2509 private function validate_event_schema($post_id, $fields)
2510 {
2511 $errors = [];
2512
2513 if (empty($fields['name']) || trim($fields['name']) === '') {
2514 $errors[] = [
2515 'field' => 'name',
2516 'message' => 'Event schema requires a <strong>Name</strong>.'
2517 ];
2518 }
2519
2520 if (empty($fields['startDate']) || trim($fields['startDate']) === '') {
2521 $errors[] = [
2522 'field' => 'startDate',
2523 'message' => 'Event schema requires a <strong>Start Date</strong>.'
2524 ];
2525 }
2526
2527 return $errors;
2528 }
2529
2530 /**
2531 * Format an event date with the site's timezone offset for ISO 8601
2532 */
2533 private function format_event_date($date_string)
2534 {
2535 if (empty($date_string)) {
2536 return $date_string;
2537 }
2538
2539 // If it already has timezone info, return as-is
2540 if (preg_match('/[+-]\d{2}:\d{2}$|Z$/', $date_string)) {
2541 return $date_string;
2542 }
2543
2544 // Append WordPress site timezone offset
2545 $timezone = wp_timezone();
2546 try {
2547 $dt = new \DateTime($date_string, $timezone);
2548 return $dt->format('c'); // ISO 8601 with timezone
2549 } catch (\Exception $e) {
2550 return $date_string;
2551 }
2552 }
2553
2554 /**
2555 * Generate Event JSON-LD
2556 */
2557 private function generate_event_json_ld($fields, $post)
2558 {
2559 $data = [];
2560
2561 if (!empty($fields['name'])) {
2562 $data['name'] = $fields['name'];
2563 }
2564 if (!empty($fields['startDate'])) {
2565 $data['startDate'] = $this->format_event_date($fields['startDate']);
2566 }
2567 if (!empty($fields['endDate'])) {
2568 $data['endDate'] = $this->format_event_date($fields['endDate']);
2569 }
2570 if (!empty($fields['eventStatus'])) {
2571 $data['eventStatus'] = 'https://schema.org/' . $fields['eventStatus'];
2572 }
2573 if (!empty($fields['eventAttendanceMode'])) {
2574 $data['eventAttendanceMode'] = 'https://schema.org/' . $fields['eventAttendanceMode'];
2575 }
2576 if (!empty($fields['description'])) {
2577 $data['description'] = $fields['description'];
2578 }
2579 if (!empty($fields['image'])) {
2580 $data['image'] = $fields['image'];
2581 }
2582
2583 if (!empty($fields['location_name']) || !empty($fields['location_address'])) {
2584 $data['location'] = [
2585 '@type' => 'Place',
2586 ];
2587 if (!empty($fields['location_name'])) {
2588 $data['location']['name'] = $fields['location_name'];
2589 }
2590 if (!empty($fields['location_address'])) {
2591 $data['location']['address'] = [
2592 '@type' => 'PostalAddress',
2593 'streetAddress' => $fields['location_address'],
2594 ];
2595 }
2596 }
2597
2598 if (!empty($fields['organizer_name'])) {
2599 $data['organizer'] = [
2600 '@type' => 'Organization',
2601 'name' => $fields['organizer_name'],
2602 ];
2603 if (!empty($fields['organizer_url'])) {
2604 $data['organizer']['url'] = $fields['organizer_url'];
2605 }
2606 }
2607
2608 if (!empty($fields['offer_price'])) {
2609 $data['offers'] = [
2610 '@type' => 'Offer',
2611 'price' => $fields['offer_price'],
2612 'priceCurrency' => !empty($fields['offer_priceCurrency']) ? $fields['offer_priceCurrency'] : 'USD',
2613 'availability' => 'https://schema.org/' . (!empty($fields['offer_availability']) ? $fields['offer_availability'] : 'InStock'),
2614 ];
2615 if (!empty($fields['offer_url'])) {
2616 $data['offers']['url'] = $fields['offer_url'];
2617 }
2618 }
2619
2620 return $data;
2621 }
2622
2623 // ======================================================================
2624 // New Schema Type: JobPosting
2625 // ======================================================================
2626
2627 /**
2628 * Render JobPosting schema fields
2629 */
2630 private function render_jobposting_fields($fields, $index = 0)
2631 {
2632 $title = isset($fields['title']) ? $fields['title'] : '';
2633 $description = isset($fields['description']) ? $fields['description'] : '';
2634 $datePosted = isset($fields['datePosted']) ? $fields['datePosted'] : '';
2635 $validThrough = isset($fields['validThrough']) ? $fields['validThrough'] : '';
2636 $employmentType = isset($fields['employmentType']) ? $fields['employmentType'] : 'FULL_TIME';
2637 $hiringOrganization_name = isset($fields['hiringOrganization_name']) ? $fields['hiringOrganization_name'] : '';
2638 $hiringOrganization_sameAs = isset($fields['hiringOrganization_sameAs']) ? $fields['hiringOrganization_sameAs'] : '';
2639 $hiringOrganization_logo = isset($fields['hiringOrganization_logo']) ? $fields['hiringOrganization_logo'] : '';
2640 $jobLocation_address = isset($fields['jobLocation_address']) ? $fields['jobLocation_address'] : '';
2641 $baseSalary_currency = isset($fields['baseSalary_currency']) ? $fields['baseSalary_currency'] : '';
2642 $baseSalary_value = isset($fields['baseSalary_value']) ? $fields['baseSalary_value'] : '';
2643 $baseSalary_unitText = isset($fields['baseSalary_unitText']) ? $fields['baseSalary_unitText'] : 'YEAR';
2644
2645 ?>
2646 <div class="schema-field-group">
2647 <h4>Job Posting Information</h4>
2648 <p class="description" style="margin-top: 0;">Fields marked with * are required.</p>
2649
2650 <div class="schema-field">
2651 <label for="job_title_<?php echo esc_attr((string) $index); ?>">Job Title: <span style="color: #dc3232;">*</span></label>
2652 <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">
2653 </div>
2654
2655 <div class="schema-field">
2656 <label for="job_description_<?php echo esc_attr((string) $index); ?>">Description: <span style="color: #dc3232;">*</span></label>
2657 <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>
2658 </div>
2659
2660 <div class="schema-field">
2661 <label for="job_datePosted_<?php echo esc_attr((string) $index); ?>">Date Posted: <span style="color: #dc3232;">*</span></label>
2662 <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); ?>">
2663 </div>
2664
2665 <div class="schema-field">
2666 <label for="job_validThrough_<?php echo esc_attr((string) $index); ?>">Valid Through:</label>
2667 <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); ?>">
2668 </div>
2669
2670 <div class="schema-field">
2671 <label for="job_employmentType_<?php echo esc_attr((string) $index); ?>">Employment Type:</label>
2672 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][employmentType]" id="job_employmentType_<?php echo esc_attr((string) $index); ?>">
2673 <option value="FULL_TIME" <?php selected($employmentType, 'FULL_TIME'); ?>>Full Time</option>
2674 <option value="PART_TIME" <?php selected($employmentType, 'PART_TIME'); ?>>Part Time</option>
2675 <option value="CONTRACTOR" <?php selected($employmentType, 'CONTRACTOR'); ?>>Contractor</option>
2676 <option value="TEMPORARY" <?php selected($employmentType, 'TEMPORARY'); ?>>Temporary</option>
2677 <option value="INTERN" <?php selected($employmentType, 'INTERN'); ?>>Intern</option>
2678 <option value="VOLUNTEER" <?php selected($employmentType, 'VOLUNTEER'); ?>>Volunteer</option>
2679 <option value="PER_DIEM" <?php selected($employmentType, 'PER_DIEM'); ?>>Per Diem</option>
2680 <option value="OTHER" <?php selected($employmentType, 'OTHER'); ?>>Other</option>
2681 </select>
2682 </div>
2683
2684 <div class="schema-field">
2685 <label for="job_org_name_<?php echo esc_attr((string) $index); ?>">Hiring Organization: <span style="color: #dc3232;">*</span></label>
2686 <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">
2687 </div>
2688
2689 <div class="schema-field">
2690 <label for="job_org_url_<?php echo esc_attr((string) $index); ?>">Organization URL:</label>
2691 <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">
2692 </div>
2693
2694 <div class="schema-field">
2695 <label for="job_org_logo_<?php echo esc_attr((string) $index); ?>">Organization Logo URL:</label>
2696 <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">
2697 </div>
2698
2699 <div class="schema-field">
2700 <label for="job_location_<?php echo esc_attr((string) $index); ?>">Job Location: <span style="color: #dc3232;">*</span></label>
2701 <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">
2702 </div>
2703
2704 <div class="schema-field">
2705 <label for="job_salary_currency_<?php echo esc_attr((string) $index); ?>">Salary Currency:</label>
2706 <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">
2707 </div>
2708
2709 <div class="schema-field">
2710 <label for="job_salary_value_<?php echo esc_attr((string) $index); ?>">Salary Value:</label>
2711 <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">
2712 </div>
2713
2714 <div class="schema-field">
2715 <label for="job_salary_unit_<?php echo esc_attr((string) $index); ?>">Salary Unit:</label>
2716 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][baseSalary_unitText]" id="job_salary_unit_<?php echo esc_attr((string) $index); ?>">
2717 <option value="HOUR" <?php selected($baseSalary_unitText, 'HOUR'); ?>>Per Hour</option>
2718 <option value="DAY" <?php selected($baseSalary_unitText, 'DAY'); ?>>Per Day</option>
2719 <option value="WEEK" <?php selected($baseSalary_unitText, 'WEEK'); ?>>Per Week</option>
2720 <option value="MONTH" <?php selected($baseSalary_unitText, 'MONTH'); ?>>Per Month</option>
2721 <option value="YEAR" <?php selected($baseSalary_unitText, 'YEAR'); ?>>Per Year</option>
2722 </select>
2723 </div>
2724 </div>
2725 <?php
2726 }
2727
2728 /**
2729 * Validate JobPosting schema
2730 */
2731 private function validate_jobposting_schema($post_id, $fields)
2732 {
2733 $errors = [];
2734
2735 if (empty($fields['title']) || trim($fields['title']) === '') {
2736 $errors[] = ['field' => 'title', 'message' => 'Job Posting schema requires a <strong>Title</strong>.'];
2737 }
2738 if (empty($fields['description']) || trim($fields['description']) === '') {
2739 $errors[] = ['field' => 'description', 'message' => 'Job Posting schema requires a <strong>Description</strong>.'];
2740 }
2741 if (empty($fields['datePosted']) || trim($fields['datePosted']) === '') {
2742 $errors[] = ['field' => 'datePosted', 'message' => 'Job Posting schema requires a <strong>Date Posted</strong>.'];
2743 }
2744 if (empty($fields['hiringOrganization_name']) || trim($fields['hiringOrganization_name']) === '') {
2745 $errors[] = ['field' => 'hiringOrganization_name', 'message' => 'Job Posting schema requires a <strong>Hiring Organization</strong>.'];
2746 }
2747 if (empty($fields['jobLocation_address']) || trim($fields['jobLocation_address']) === '') {
2748 $errors[] = ['field' => 'jobLocation_address', 'message' => 'Job Posting schema requires a <strong>Job Location</strong>.'];
2749 }
2750
2751 return $errors;
2752 }
2753
2754 /**
2755 * Generate JobPosting JSON-LD
2756 */
2757 private function generate_jobposting_json_ld($fields, $post)
2758 {
2759 $data = [];
2760
2761 if (!empty($fields['title'])) {
2762 $data['title'] = $fields['title'];
2763 }
2764 if (!empty($fields['description'])) {
2765 $data['description'] = $fields['description'];
2766 }
2767 if (!empty($fields['datePosted'])) {
2768 $data['datePosted'] = $fields['datePosted'];
2769 }
2770 if (!empty($fields['validThrough'])) {
2771 $data['validThrough'] = $fields['validThrough'];
2772 }
2773 if (!empty($fields['employmentType'])) {
2774 $data['employmentType'] = $fields['employmentType'];
2775 }
2776
2777 if (!empty($fields['hiringOrganization_name'])) {
2778 $data['hiringOrganization'] = [
2779 '@type' => 'Organization',
2780 'name' => $fields['hiringOrganization_name'],
2781 ];
2782 if (!empty($fields['hiringOrganization_sameAs'])) {
2783 $data['hiringOrganization']['sameAs'] = $fields['hiringOrganization_sameAs'];
2784 }
2785 if (!empty($fields['hiringOrganization_logo'])) {
2786 $data['hiringOrganization']['logo'] = $fields['hiringOrganization_logo'];
2787 }
2788 }
2789
2790 if (!empty($fields['jobLocation_address'])) {
2791 $data['jobLocation'] = [
2792 '@type' => 'Place',
2793 'address' => [
2794 '@type' => 'PostalAddress',
2795 'streetAddress' => $fields['jobLocation_address'],
2796 ],
2797 ];
2798 }
2799
2800 if (!empty($fields['baseSalary_value']) && !empty($fields['baseSalary_currency'])) {
2801 $data['baseSalary'] = [
2802 '@type' => 'MonetaryAmount',
2803 'currency' => $fields['baseSalary_currency'],
2804 'value' => [
2805 '@type' => 'QuantitativeValue',
2806 'value' => $fields['baseSalary_value'],
2807 'unitText' => !empty($fields['baseSalary_unitText']) ? $fields['baseSalary_unitText'] : 'YEAR',
2808 ],
2809 ];
2810 }
2811
2812 return $data;
2813 }
2814
2815 // ======================================================================
2816 // New Schema Type: Review
2817 // ======================================================================
2818
2819 /**
2820 * Render Review schema fields
2821 */
2822 private function render_review_fields($fields, $index = 0)
2823 {
2824 $itemReviewed_name = isset($fields['itemReviewed_name']) ? $fields['itemReviewed_name'] : '';
2825 $reviewRating_ratingValue = isset($fields['reviewRating_ratingValue']) ? $fields['reviewRating_ratingValue'] : '';
2826 $reviewRating_bestRating = isset($fields['reviewRating_bestRating']) ? $fields['reviewRating_bestRating'] : '5';
2827 $author_name = isset($fields['author_name']) ? $fields['author_name'] : '';
2828 $reviewBody = isset($fields['reviewBody']) ? $fields['reviewBody'] : '';
2829
2830 ?>
2831 <div class="schema-field-group">
2832 <h4>Review Information</h4>
2833 <p class="description" style="margin-top: 0;">Fields marked with * are required.</p>
2834
2835 <div class="schema-field">
2836 <label for="review_item_<?php echo esc_attr((string) $index); ?>">Item Reviewed: <span style="color: #dc3232;">*</span></label>
2837 <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">
2838 </div>
2839
2840 <div class="schema-field">
2841 <label for="review_rating_<?php echo esc_attr((string) $index); ?>">Rating Value: <span style="color: #dc3232;">*</span></label>
2842 <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">
2843 </div>
2844
2845 <div class="schema-field">
2846 <label for="review_best_rating_<?php echo esc_attr((string) $index); ?>">Best Rating:</label>
2847 <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">
2848 </div>
2849
2850 <div class="schema-field">
2851 <label for="review_author_<?php echo esc_attr((string) $index); ?>">Author Name: <span style="color: #dc3232;">*</span></label>
2852 <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">
2853 </div>
2854
2855 <div class="schema-field">
2856 <label for="review_body_<?php echo esc_attr((string) $index); ?>">Review Body:</label>
2857 <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>
2858 </div>
2859 </div>
2860 <?php
2861 }
2862
2863 /**
2864 * Validate Review schema
2865 */
2866 private function validate_review_schema($post_id, $fields)
2867 {
2868 $errors = [];
2869
2870 if (empty($fields['itemReviewed_name']) || trim($fields['itemReviewed_name']) === '') {
2871 $errors[] = ['field' => 'itemReviewed_name', 'message' => 'Review schema requires an <strong>Item Reviewed</strong> name.'];
2872 }
2873 if (empty($fields['reviewRating_ratingValue']) || floatval($fields['reviewRating_ratingValue']) <= 0) {
2874 $errors[] = ['field' => 'reviewRating_ratingValue', 'message' => 'Review schema requires a <strong>Rating Value</strong>.'];
2875 }
2876 if (empty($fields['author_name']) || trim($fields['author_name']) === '') {
2877 $errors[] = ['field' => 'author_name', 'message' => 'Review schema requires an <strong>Author Name</strong>.'];
2878 }
2879
2880 return $errors;
2881 }
2882
2883 /**
2884 * Generate Review JSON-LD
2885 */
2886 private function generate_review_json_ld($fields, $post)
2887 {
2888 $data = [];
2889
2890 if (!empty($fields['itemReviewed_name'])) {
2891 $data['itemReviewed'] = [
2892 '@type' => 'Thing',
2893 'name' => $fields['itemReviewed_name'],
2894 ];
2895 }
2896
2897 if (!empty($fields['reviewRating_ratingValue'])) {
2898 $data['reviewRating'] = [
2899 '@type' => 'Rating',
2900 'ratingValue' => $fields['reviewRating_ratingValue'],
2901 'bestRating' => !empty($fields['reviewRating_bestRating']) ? $fields['reviewRating_bestRating'] : '5',
2902 ];
2903 }
2904
2905 if (!empty($fields['author_name'])) {
2906 $data['author'] = [
2907 '@type' => 'Person',
2908 'name' => $fields['author_name'],
2909 ];
2910 }
2911
2912 if (!empty($fields['reviewBody'])) {
2913 $data['reviewBody'] = $fields['reviewBody'];
2914 }
2915
2916 return $data;
2917 }
2918
2919 // ======================================================================
2920 // New Schema Type: Course
2921 // ======================================================================
2922
2923 /**
2924 * Render Course schema fields
2925 */
2926 private function render_course_fields($fields, $index = 0)
2927 {
2928 $name = isset($fields['name']) ? $fields['name'] : '';
2929 $description = isset($fields['description']) ? $fields['description'] : '';
2930 $provider_name = isset($fields['provider_name']) ? $fields['provider_name'] : '';
2931 $provider_sameAs = isset($fields['provider_sameAs']) ? $fields['provider_sameAs'] : '';
2932 $courseInstance_courseMode = isset($fields['courseInstance_courseMode']) ? $fields['courseInstance_courseMode'] : 'online';
2933 $courseInstance_instructor_name = isset($fields['courseInstance_instructor_name']) ? $fields['courseInstance_instructor_name'] : '';
2934
2935 ?>
2936 <div class="schema-field-group">
2937 <h4>Course Information</h4>
2938 <p class="description" style="margin-top: 0;">Fields marked with * are required.</p>
2939
2940 <div class="schema-field">
2941 <label for="course_name_<?php echo esc_attr((string) $index); ?>">Course Name: <span style="color: #dc3232;">*</span></label>
2942 <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">
2943 </div>
2944
2945 <div class="schema-field">
2946 <label for="course_description_<?php echo esc_attr((string) $index); ?>">Description:</label>
2947 <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>
2948 </div>
2949
2950 <div class="schema-field">
2951 <label for="course_provider_<?php echo esc_attr((string) $index); ?>">Provider Name: <span style="color: #dc3232;">*</span></label>
2952 <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">
2953 </div>
2954
2955 <div class="schema-field">
2956 <label for="course_provider_url_<?php echo esc_attr((string) $index); ?>">Provider URL:</label>
2957 <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">
2958 </div>
2959
2960 <div class="schema-field">
2961 <label for="course_mode_<?php echo esc_attr((string) $index); ?>">Course Mode:</label>
2962 <select name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][courseInstance_courseMode]" id="course_mode_<?php echo esc_attr((string) $index); ?>">
2963 <option value="online" <?php selected($courseInstance_courseMode, 'online'); ?>>Online</option>
2964 <option value="onsite" <?php selected($courseInstance_courseMode, 'onsite'); ?>>On-site</option>
2965 <option value="blended" <?php selected($courseInstance_courseMode, 'blended'); ?>>Blended</option>
2966 </select>
2967 </div>
2968
2969 <div class="schema-field">
2970 <label for="course_instructor_<?php echo esc_attr((string) $index); ?>">Instructor Name:</label>
2971 <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">
2972 </div>
2973 </div>
2974 <?php
2975 }
2976
2977 /**
2978 * Validate Course schema
2979 */
2980 private function validate_course_schema($post_id, $fields)
2981 {
2982 $errors = [];
2983
2984 if (empty($fields['name']) || trim($fields['name']) === '') {
2985 $errors[] = ['field' => 'name', 'message' => 'Course schema requires a <strong>Name</strong>.'];
2986 }
2987 if (empty($fields['provider_name']) || trim($fields['provider_name']) === '') {
2988 $errors[] = ['field' => 'provider_name', 'message' => 'Course schema requires a <strong>Provider Name</strong>.'];
2989 }
2990
2991 return $errors;
2992 }
2993
2994 /**
2995 * Generate Course JSON-LD
2996 */
2997 private function generate_course_json_ld($fields, $post)
2998 {
2999 $data = [];
3000
3001 if (!empty($fields['name'])) {
3002 $data['name'] = $fields['name'];
3003 }
3004 if (!empty($fields['description'])) {
3005 $data['description'] = $fields['description'];
3006 }
3007
3008 if (!empty($fields['provider_name'])) {
3009 $data['provider'] = [
3010 '@type' => 'Organization',
3011 'name' => $fields['provider_name'],
3012 ];
3013 if (!empty($fields['provider_sameAs'])) {
3014 $data['provider']['sameAs'] = $fields['provider_sameAs'];
3015 }
3016 }
3017
3018 if (!empty($fields['courseInstance_courseMode']) || !empty($fields['courseInstance_instructor_name'])) {
3019 $data['hasCourseInstance'] = [
3020 '@type' => 'CourseInstance',
3021 ];
3022 if (!empty($fields['courseInstance_courseMode'])) {
3023 $data['hasCourseInstance']['courseMode'] = $fields['courseInstance_courseMode'];
3024 }
3025 if (!empty($fields['courseInstance_instructor_name'])) {
3026 $data['hasCourseInstance']['instructor'] = [
3027 '@type' => 'Person',
3028 'name' => $fields['courseInstance_instructor_name'],
3029 ];
3030 }
3031 }
3032
3033 return $data;
3034 }
3035
3036 // ======================================================================
3037 // New Schema Type: Organization
3038 // ======================================================================
3039
3040 /**
3041 * Render Organization schema fields
3042 */
3043 private function render_organization_fields($fields, $index = 0)
3044 {
3045 $name = isset($fields['name']) ? $fields['name'] : '';
3046 $url = isset($fields['url']) ? $fields['url'] : '';
3047 $logo = isset($fields['logo']) ? $fields['logo'] : '';
3048 $contactPoint_telephone = isset($fields['contactPoint_telephone']) ? $fields['contactPoint_telephone'] : '';
3049 $contactPoint_contactType = isset($fields['contactPoint_contactType']) ? $fields['contactPoint_contactType'] : '';
3050 $sameAs = isset($fields['sameAs']) ? $fields['sameAs'] : '';
3051
3052 ?>
3053 <div class="schema-field-group">
3054 <h4>Organization Information</h4>
3055
3056 <div class="schema-field">
3057 <label for="org_name_<?php echo esc_attr((string) $index); ?>">Organization Name:</label>
3058 <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">
3059 </div>
3060
3061 <div class="schema-field">
3062 <label for="org_url_<?php echo esc_attr((string) $index); ?>">URL:</label>
3063 <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">
3064 </div>
3065
3066 <div class="schema-field">
3067 <label for="org_logo_<?php echo esc_attr((string) $index); ?>">Logo URL:</label>
3068 <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">
3069 </div>
3070
3071 <div class="schema-field">
3072 <label for="org_phone_<?php echo esc_attr((string) $index); ?>">Contact Telephone:</label>
3073 <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">
3074 </div>
3075
3076 <div class="schema-field">
3077 <label for="org_contact_type_<?php echo esc_attr((string) $index); ?>">Contact Type:</label>
3078 <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">
3079 </div>
3080
3081 <div class="schema-field">
3082 <label for="org_sameas_<?php echo esc_attr((string) $index); ?>">Social Profiles (one URL per line):</label>
3083 <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>
3084 </div>
3085 </div>
3086 <?php
3087 }
3088
3089 /**
3090 * Validate Organization schema
3091 */
3092 private function validate_organization_schema($post_id, $fields)
3093 {
3094 return [];
3095 }
3096
3097 /**
3098 * Generate Organization JSON-LD
3099 */
3100 private function generate_organization_json_ld($fields, $post)
3101 {
3102 $data = [];
3103
3104 if (!empty($fields['name'])) {
3105 $data['name'] = $fields['name'];
3106 }
3107 if (!empty($fields['url'])) {
3108 $data['url'] = $fields['url'];
3109 }
3110 if (!empty($fields['logo'])) {
3111 $data['logo'] = [
3112 '@type' => 'ImageObject',
3113 'url' => $fields['logo'],
3114 ];
3115 }
3116
3117 if (!empty($fields['contactPoint_telephone']) || !empty($fields['contactPoint_contactType'])) {
3118 $data['contactPoint'] = [
3119 '@type' => 'ContactPoint',
3120 ];
3121 if (!empty($fields['contactPoint_telephone'])) {
3122 $data['contactPoint']['telephone'] = $fields['contactPoint_telephone'];
3123 }
3124 if (!empty($fields['contactPoint_contactType'])) {
3125 $data['contactPoint']['contactType'] = $fields['contactPoint_contactType'];
3126 }
3127 }
3128
3129 if (!empty($fields['sameAs'])) {
3130 $urls = array_filter(array_map('trim', explode("\n", $fields['sameAs'])));
3131 if (!empty($urls)) {
3132 $data['sameAs'] = $urls;
3133 }
3134 }
3135
3136 return $data;
3137 }
3138
3139 // ======================================================================
3140 // New Schema Type: Person
3141 // ======================================================================
3142
3143 /**
3144 * Render Person schema fields
3145 */
3146 private function render_person_fields($fields, $index = 0)
3147 {
3148 // Auto-populate from post author if fields are empty
3149 $author_id = 0;
3150 global $post;
3151 if ($post && isset($post->post_author)) {
3152 $author_id = $post->post_author;
3153 }
3154
3155 $name = isset($fields['name']) ? $fields['name'] : '';
3156 $url = isset($fields['url']) ? $fields['url'] : '';
3157 $jobTitle = isset($fields['jobTitle']) ? $fields['jobTitle'] : '';
3158 $worksFor = isset($fields['worksFor']) ? $fields['worksFor'] : '';
3159 $email = isset($fields['email']) ? $fields['email'] : '';
3160 $image = isset($fields['image']) ? $fields['image'] : '';
3161 $sameAs = isset($fields['sameAs']) ? $fields['sameAs'] : '';
3162
3163 // Build auto-populate hints from author profile
3164 $author_name = $author_id ? get_the_author_meta('display_name', $author_id) : '';
3165 $author_url = $author_id ? get_author_posts_url($author_id) : '';
3166 $author_email = $author_id ? get_the_author_meta('user_email', $author_id) : '';
3167 $author_avatar = $author_id ? get_avatar_url($author_id, ['size' => 256]) : '';
3168 $author_desc = $author_id ? get_the_author_meta('description', $author_id) : '';
3169
3170 ?>
3171 <div class="schema-field-group">
3172 <h4>Person Information</h4>
3173 <?php if ($author_id && $author_name): ?>
3174 <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>
3175 <?php endif; ?>
3176
3177 <div class="schema-field">
3178 <label for="person_name_<?php echo esc_attr((string) $index); ?>">Name:</label>
3179 <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">
3180 </div>
3181
3182 <div class="schema-field">
3183 <label for="person_url_<?php echo esc_attr((string) $index); ?>">URL:</label>
3184 <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">
3185 </div>
3186
3187 <div class="schema-field">
3188 <label for="person_jobtitle_<?php echo esc_attr((string) $index); ?>">Job Title:</label>
3189 <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">
3190 </div>
3191
3192 <div class="schema-field">
3193 <label for="person_worksfor_<?php echo esc_attr((string) $index); ?>">Works For (Organization Name):</label>
3194 <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">
3195 </div>
3196
3197 <div class="schema-field">
3198 <label for="person_email_<?php echo esc_attr((string) $index); ?>">Email:</label>
3199 <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]">
3200 </div>
3201
3202 <div class="schema-field">
3203 <label for="person_image_<?php echo esc_attr((string) $index); ?>">Image URL:</label>
3204 <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">
3205 </div>
3206
3207 <div class="schema-field">
3208 <label for="person_sameas_<?php echo esc_attr((string) $index); ?>">Social Profiles (one URL per line):</label>
3209 <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>
3210 </div>
3211 </div>
3212 <?php
3213 }
3214
3215 /**
3216 * Validate Person schema
3217 */
3218 private function validate_person_schema($post_id, $fields)
3219 {
3220 return [];
3221 }
3222
3223 /**
3224 * Generate Person JSON-LD
3225 */
3226 private function generate_person_json_ld($fields, $post)
3227 {
3228 $data = [];
3229
3230 // Auto-populate from post author when fields are empty
3231 $author_id = isset($post->post_author) ? $post->post_author : 0;
3232
3233 $name = !empty($fields['name']) ? $fields['name'] : ($author_id ? get_the_author_meta('display_name', $author_id) : '');
3234 $url = !empty($fields['url']) ? $fields['url'] : ($author_id ? get_author_posts_url($author_id) : '');
3235 $image = !empty($fields['image']) ? $fields['image'] : ($author_id ? get_avatar_url($author_id, ['size' => 256]) : '');
3236
3237 if (!empty($name)) {
3238 $data['name'] = $name;
3239 }
3240 if (!empty($url)) {
3241 $data['url'] = $url;
3242 }
3243 if (!empty($fields['jobTitle'])) {
3244 $data['jobTitle'] = $fields['jobTitle'];
3245 }
3246 if (!empty($fields['worksFor'])) {
3247 $data['worksFor'] = [
3248 '@type' => 'Organization',
3249 'name' => $fields['worksFor'],
3250 ];
3251 }
3252 if (!empty($fields['email'])) {
3253 $data['email'] = $fields['email'];
3254 }
3255 if (!empty($image)) {
3256 $data['image'] = $image;
3257 }
3258
3259 if (!empty($fields['sameAs'])) {
3260 $urls = array_filter(array_map('trim', explode("\n", $fields['sameAs'])));
3261 if (!empty($urls)) {
3262 $data['sameAs'] = $urls;
3263 }
3264 }
3265
3266 return $data;
3267 }
3268
3269 // ======================================================================
3270 // New Schema Type: WebSite
3271 // ======================================================================
3272
3273 /**
3274 * Render WebSite schema fields
3275 */
3276 private function render_website_fields($fields, $index = 0)
3277 {
3278 $name = isset($fields['name']) ? $fields['name'] : '';
3279 $url = isset($fields['url']) ? $fields['url'] : '';
3280 $searchbox_enabled = isset($fields['searchbox_enabled']) ? (bool) $fields['searchbox_enabled'] : false;
3281 $searchbox_query_input = isset($fields['searchbox_query_input']) ? $fields['searchbox_query_input'] : '';
3282
3283 ?>
3284 <div class="schema-field-group">
3285 <h4>Website Information</h4>
3286
3287 <div class="schema-field">
3288 <label for="website_name_<?php echo esc_attr((string) $index); ?>">Website Name:</label>
3289 <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">
3290 </div>
3291
3292 <div class="schema-field">
3293 <label for="website_url_<?php echo esc_attr((string) $index); ?>">URL:</label>
3294 <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">
3295 </div>
3296
3297 <div class="schema-field">
3298 <label>
3299 <input type="checkbox" name="schema_markup[types][<?php echo esc_attr((string) $index); ?>][fields][searchbox_enabled]" value="1" <?php checked($searchbox_enabled, true); ?>>
3300 Enable Sitelinks Searchbox
3301 </label>
3302 <p class="description">Adds SearchAction for Google Sitelinks Searchbox in SERPs.</p>
3303 </div>
3304
3305 <div class="schema-field">
3306 <label for="website_query_<?php echo esc_attr((string) $index); ?>">Search URL Template:</label>
3307 <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}">
3308 <p class="description">Search URL template (default: /?s={search_term_string})</p>
3309 </div>
3310 </div>
3311 <?php
3312 }
3313
3314 /**
3315 * Validate WebSite schema
3316 */
3317 private function validate_website_schema($post_id, $fields)
3318 {
3319 return [];
3320 }
3321
3322 /**
3323 * Generate WebSite JSON-LD
3324 */
3325 private function generate_website_json_ld($fields, $post)
3326 {
3327 $data = [];
3328
3329 if (!empty($fields['name'])) {
3330 $data['name'] = $fields['name'];
3331 }
3332 if (!empty($fields['url'])) {
3333 $data['url'] = $fields['url'];
3334 }
3335
3336 if (!empty($fields['searchbox_enabled'])) {
3337 $search_target = !empty($fields['searchbox_query_input']) ? $fields['searchbox_query_input'] : '/?s={search_term_string}';
3338 $base_url = !empty($fields['url']) ? rtrim($fields['url'], '/') : home_url();
3339 $data['potentialAction'] = [
3340 '@type' => 'SearchAction',
3341 'target' => [
3342 '@type' => 'EntryPoint',
3343 'urlTemplate' => $base_url . $search_target,
3344 ],
3345 'query-input' => 'required name=search_term_string',
3346 ];
3347 }
3348
3349 return $data;
3350 }
3351
3352 // ======================================================================
3353 // New Schema Type: NewsArticle
3354 // ======================================================================
3355
3356 /**
3357 * Render NewsArticle schema fields
3358 */
3359 private function render_newsarticle_fields($fields, $index = 0)
3360 {
3361 $organization_name = isset($fields['organization_name']) ? $fields['organization_name'] : '';
3362 $organization_logo = isset($fields['organization_logo']) ? $fields['organization_logo'] : '';
3363
3364 ?>
3365 <div class="schema-field-group">
3366 <?php $this->render_override_fields_section($fields, $index, 'NewsArticle', true); ?>
3367
3368 <h4>News Article Information</h4>
3369 <p class="description" style="margin-top: 0;">Optional fields to enhance your news article schema.</p>
3370
3371 <div class="schema-field">
3372 <label for="newsarticle_organization_name_<?php echo esc_attr((string) $index); ?>">Organization Name:</label>
3373 <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">
3374 <p class="description">The name of the organization that published this article.</p>
3375 </div>
3376
3377 <div class="schema-field">
3378 <label for="newsarticle_organization_logo_<?php echo esc_attr((string) $index); ?>">Organization Logo:</label>
3379 <div class="logo-upload-container">
3380 <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;">
3381 </div>
3382 <p class="description">Logo of the organization (recommended for better rich results).</p>
3383 </div>
3384 </div>
3385 <?php
3386 }
3387
3388 /**
3389 * Validate NewsArticle schema (same checks as Article)
3390 */
3391 private function validate_newsarticle_schema($post_id, $fields)
3392 {
3393 $errors = [];
3394
3395 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
3396 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
3397 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
3398
3399 if (empty($effective_title) || trim($effective_title) === '') {
3400 $errors[] = [
3401 'field' => 'headline',
3402 'message' => 'News Article schema requires a <strong>Headline</strong>. Please add a post title or override it in the Override Default Fields section.'
3403 ];
3404 }
3405
3406 if (empty($effective_description) || trim($effective_description) === '') {
3407 $errors[] = [
3408 'field' => 'description',
3409 'message' => 'News Article schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
3410 ];
3411 }
3412
3413 if (empty($effective_image)) {
3414 $errors[] = [
3415 'field' => 'image',
3416 'message' => 'News Article schema requires an <strong>Image</strong>. Please set a featured image or override it in the Override Default Fields section.'
3417 ];
3418 }
3419
3420 return $errors;
3421 }
3422
3423 /**
3424 * Generate NewsArticle JSON-LD (same shape as Article)
3425 */
3426 private function generate_newsarticle_json_ld($fields, $post)
3427 {
3428 return $this->generate_article_json_ld($fields, $post);
3429 }
3430
3431 // ======================================================================
3432 // Cross-plugin schema deduplication
3433 // ======================================================================
3434
3435 /**
3436 * Check if a conflicting SEO plugin already outputs this schema type.
3437 *
3438 * Per-type suppression: only suppress the specific @type that a competing
3439 * plugin is known to output, not all MetaSync schema.
3440 *
3441 * The type key is matched case-insensitively. Stored per-post type keys use
3442 * mixed casing — the metabox dropdown and the MCP tool enum both offer
3443 * 'article', 'product' and 'recipe' in lowercase while the remaining keys
3444 * ('LocalBusiness', 'NewsArticle', 'Organization', …) keep their Schema.org
3445 * casing — and the hardcoded callers in output_schema_markup() pass the
3446 * Schema.org spelling. Comparing the raw value against a single casing can
3447 * therefore only ever match part of the key set, so both sides are folded to
3448 * lowercase here. This mirrors the strtolower() matching the external
3449 * importer already uses on incoming schema types.
3450 *
3451 * @param string $type Schema.org type name or stored schema type key
3452 * @return bool True if MetaSync should output this type; false to suppress
3453 */
3454 private function should_output_schema_type($type)
3455 {
3456 $type_key = strtolower((string) $type);
3457
3458 // Yoast SEO — outputs these types via its schema graph
3459 if (class_exists('WPSEO_Schema_Context')) {
3460 $yoast_types = ['article', 'newsarticle', 'website', 'organization', 'person', 'webpage', 'breadcrumblist'];
3461 if (in_array($type_key, $yoast_types, true)) {
3462 return false;
3463 }
3464 }
3465
3466 // Yoast Local SEO — specifically for LocalBusiness
3467 if ($type_key === 'localbusiness' && class_exists('WPSEO_Local_Core')) {
3468 return false;
3469 }
3470
3471 // Rank Math — outputs these types via its schema module
3472 if (class_exists('RankMath\\Schema\\JsonLD')) {
3473 $rankmath_types = ['article', 'newsarticle', 'website', 'organization', 'person', 'webpage', 'breadcrumblist'];
3474 if (in_array($type_key, $rankmath_types, true)) {
3475 return false;
3476 }
3477 }
3478
3479 // AIOSEO — outputs these types when its schema feature is active
3480 if (has_filter('aioseo_schema')) {
3481 $aioseo_types = ['article', 'newsarticle', 'website', 'organization', 'person', 'webpage', 'breadcrumblist'];
3482 if (in_array($type_key, $aioseo_types, true)) {
3483 return false;
3484 }
3485 }
3486
3487 return true;
3488 }
3489
3490 // ======================================================================
3491 // WooCommerce integration for Product schema
3492 // ======================================================================
3493
3494 /**
3495 * Generate LocalBusiness JSON-LD
3496 */
3497 private function generate_local_business_json_ld($fields)
3498 {
3499 // Deduplication: suppress if Yoast Local SEO is active
3500 // (also checked upstream in dedup_check_for_type, but kept as safety net)
3501 if (class_exists('WPSEO_Local_Core')) {
3502 return false;
3503 }
3504
3505 $data = [];
3506
3507 if (!empty($fields['business_name'])) {
3508 $data['name'] = $fields['business_name'];
3509 }
3510
3511 if (!empty($fields['description'])) {
3512 $data['description'] = $fields['description'];
3513 }
3514
3515 if (!empty($fields['url'])) {
3516 $data['url'] = $fields['url'];
3517 }
3518
3519 if (!empty($fields['telephone'])) {
3520 $data['telephone'] = $fields['telephone'];
3521 }
3522
3523 if (!empty($fields['price_range'])) {
3524 $data['priceRange'] = $fields['price_range'];
3525 }
3526
3527 if (!empty($fields['image'])) {
3528 $data['image'] = $fields['image'];
3529 }
3530
3531 // Build PostalAddress sub-object
3532 $address = [];
3533 if (!empty($fields['street_address'])) {
3534 $address['streetAddress'] = $fields['street_address'];
3535 }
3536 if (!empty($fields['city'])) {
3537 $address['addressLocality'] = $fields['city'];
3538 }
3539 if (!empty($fields['state'])) {
3540 $address['addressRegion'] = $fields['state'];
3541 }
3542 if (!empty($fields['postal_code'])) {
3543 $address['postalCode'] = $fields['postal_code'];
3544 }
3545 if (!empty($fields['country'])) {
3546 $address['addressCountry'] = $fields['country'];
3547 }
3548 if (!empty($address)) {
3549 $address['@type'] = 'PostalAddress';
3550 $data['address'] = $address;
3551 }
3552
3553 // Build GeoCoordinates sub-object
3554 if (!empty($fields['latitude']) && !empty($fields['longitude'])) {
3555 $data['geo'] = [
3556 '@type' => 'GeoCoordinates',
3557 'latitude' => $fields['latitude'],
3558 'longitude' => $fields['longitude'],
3559 ];
3560 }
3561
3562 // Build openingHoursSpecification
3563 if (!empty($fields['opening_hours']) && is_array($fields['opening_hours'])) {
3564 $specs = [];
3565 foreach ($fields['opening_hours'] as $hours) {
3566 if (!empty($hours['day'])) {
3567 $spec = [
3568 '@type' => 'OpeningHoursSpecification',
3569 'dayOfWeek' => $hours['day'],
3570 ];
3571 if (!empty($hours['open'])) {
3572 $spec['opens'] = $hours['open'];
3573 }
3574 if (!empty($hours['close'])) {
3575 $spec['closes'] = $hours['close'];
3576 }
3577 $specs[] = $spec;
3578 }
3579 }
3580 if (!empty($specs)) {
3581 $data['openingHoursSpecification'] = $specs;
3582 }
3583 }
3584
3585 return $data;
3586 }
3587
3588 /**
3589 * Generate HowTo JSON-LD
3590 */
3591 private function generate_howto_json_ld($fields)
3592 {
3593 $data = [];
3594
3595 // Steps
3596 if (!empty($fields['steps']) && is_array($fields['steps'])) {
3597 $data['step'] = [];
3598 foreach ($fields['steps'] as $step) {
3599 if (!empty($step['instructions'])) {
3600 $step_data = [
3601 '@type' => 'HowToStep',
3602 'text' => $step['instructions'],
3603 ];
3604 if (!empty($step['image'])) {
3605 $step_data['image'] = $step['image'];
3606 }
3607 $data['step'][] = $step_data;
3608 }
3609 }
3610 }
3611
3612 // Supplies
3613 if (!empty($fields['supplies']) && is_array($fields['supplies'])) {
3614 $data['supply'] = [];
3615 foreach ($fields['supplies'] as $supply) {
3616 if (!empty($supply)) {
3617 $data['supply'][] = [
3618 '@type' => 'HowToSupply',
3619 'name' => $supply,
3620 ];
3621 }
3622 }
3623 }
3624
3625 // Tools
3626 if (!empty($fields['tools']) && is_array($fields['tools'])) {
3627 $data['tool'] = [];
3628 foreach ($fields['tools'] as $tool) {
3629 if (!empty($tool)) {
3630 $data['tool'][] = [
3631 '@type' => 'HowToTool',
3632 'name' => $tool,
3633 ];
3634 }
3635 }
3636 }
3637
3638 // Total time (ISO 8601 duration)
3639 if (!empty($fields['total_time'])) {
3640 $data['totalTime'] = 'PT' . $fields['total_time'] . 'M';
3641 }
3642
3643 // Estimated cost
3644 if (!empty($fields['estimated_cost'])) {
3645 $data['estimatedCost'] = [
3646 '@type' => 'MonetaryAmount',
3647 'value' => $fields['estimated_cost'],
3648 ];
3649 }
3650
3651 return $data;
3652 }
3653
3654 /**
3655 * Generate VideoObject JSON-LD
3656 */
3657 private function generate_video_object_json_ld($fields)
3658 {
3659 $data = [];
3660
3661 if (!empty($fields['video_name'])) {
3662 $data['name'] = $fields['video_name'];
3663 }
3664
3665 if (!empty($fields['video_description'])) {
3666 $data['description'] = $fields['video_description'];
3667 }
3668
3669 if (!empty($fields['thumbnail_url'])) {
3670 $data['thumbnailUrl'] = $fields['thumbnail_url'];
3671 }
3672
3673 if (!empty($fields['upload_date'])) {
3674 $data['uploadDate'] = $fields['upload_date'];
3675 }
3676
3677 if (!empty($fields['content_url'])) {
3678 $data['contentUrl'] = $fields['content_url'];
3679 }
3680
3681 if (!empty($fields['embed_url'])) {
3682 $data['embedUrl'] = $fields['embed_url'];
3683 }
3684
3685 if (!empty($fields['duration'])) {
3686 $data['duration'] = 'PT' . $fields['duration'] . 'M';
3687 }
3688
3689 return $data;
3690 }
3691
3692 /**
3693 * Enqueue admin scripts
3694 */
3695 public function enqueue_admin_scripts($hook)
3696 {
3697 // Only load on post/page edit screens
3698 if ($hook !== 'post.php' && $hook !== 'post-new.php') {
3699 return;
3700 }
3701
3702 wp_enqueue_script('jquery');
3703
3704 // Enqueue WordPress media scripts for image uploader
3705 wp_enqueue_media();
3706
3707 // Enqueue schema markup admin CSS
3708 wp_enqueue_style(
3709 'metasync-schema-markup-admin',
3710 plugin_dir_url(__FILE__) . 'css/schema-markup-admin.css',
3711 [],
3712 $this->version,
3713 'all'
3714 );
3715
3716 // Enqueue schema markup admin JS
3717 wp_enqueue_script(
3718 'metasync-schema-markup-admin',
3719 plugin_dir_url(__FILE__) . 'js/schema-markup-admin.js',
3720 ['jquery', 'wp-util'],
3721 $this->version,
3722 true
3723 );
3724
3725 // Localize script with nonces
3726 wp_localize_script(
3727 'metasync-schema-markup-admin',
3728 'metasyncSchemaMarkup',
3729 [
3730 'ajaxurl' => admin_url('admin-ajax.php'),
3731 'schema_fields_nonce' => wp_create_nonce('metasync_schema_fields_nonce'),
3732 'preview_schema_nonce' => wp_create_nonce('metasync_preview_schema_nonce')
3733 ]
3734 );
3735 }
3736
3737 /**
3738 * Post types the schema markup UI is registered for
3739 *
3740 * Single source of truth shared by the meta box registration and the AJAX
3741 * handlers so the two cannot drift apart.
3742 *
3743 * @return array List of supported post type slugs
3744 */
3745 private function get_schema_supported_post_types()
3746 {
3747 return ['post', 'page'];
3748 }
3749
3750 /**
3751 * Resolve and authorize the post targeted by a schema markup AJAX request
3752 *
3753 * Terminates the request with a JSON error when the current user may not
3754 * edit the requested post, when the post does not exist, or when the post
3755 * type is not one the schema UI applies to. A missing post and a forbidden
3756 * post return the same 403 response so the endpoint cannot be used to
3757 * enumerate which post IDs exist.
3758 *
3759 * @param int $post_id Requested post ID
3760 * @return WP_Post The authorized post object
3761 */
3762 private function authorize_schema_post_request($post_id)
3763 {
3764 $post = get_post($post_id);
3765
3766 if (!$post || !current_user_can('edit_post', $post_id)) {
3767 wp_send_json_error(['message' => 'Permission denied'], 403);
3768 } elseif (!in_array($post->post_type, $this->get_schema_supported_post_types(), true)) {
3769 wp_send_json_error(['message' => 'Unsupported post type'], 400);
3770 }
3771
3772 return $post;
3773 }
3774
3775 /**
3776 * AJAX handler for getting schema fields
3777 */
3778 public function ajax_get_schema_fields()
3779 {
3780 // Verify nonce
3781 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_schema_fields_nonce')) {
3782 wp_send_json_error(['message' => 'Security check failed'], 403);
3783 }
3784
3785 $schema_type = isset($_POST['schema_type']) ? sanitize_text_field(wp_unslash($_POST['schema_type'])) : '';
3786 $index = isset($_POST['index']) ? intval($_POST['index']) : 0;
3787 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
3788
3789 // Set global post for override fields rendering
3790 if ($post_id) {
3791 global $post;
3792 $post = $this->authorize_schema_post_request($post_id);
3793 } elseif (!current_user_can('edit_posts')) {
3794 // No post context: the markup rendered below carries no post data,
3795 // but the editor UI is still limited to users who may author posts.
3796 wp_send_json_error(['message' => 'Permission denied'], 403);
3797 }
3798
3799 ob_start();
3800 $this->render_schema_fields($schema_type, [], $index);
3801 $html = ob_get_clean();
3802
3803 wp_send_json_success($html);
3804 }
3805
3806 /**
3807 * AJAX handler for previewing schema markup
3808 */
3809 public function ajax_preview_schema()
3810 {
3811 // Verify nonce
3812 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_preview_schema_nonce')) {
3813 wp_send_json_error(['message' => 'Security check failed'], 403);
3814 }
3815
3816 // Get post ID
3817 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
3818 if (!$post_id) {
3819 wp_send_json_error(['message' => 'Invalid post ID']);
3820 }
3821
3822 // Get post object, enforcing that the current user may edit it
3823 $post = $this->authorize_schema_post_request($post_id);
3824
3825 // Check if schema is enabled
3826 $schema_enabled = isset($_POST['schema_enabled']) && rest_sanitize_boolean(wp_unslash($_POST['schema_enabled']));
3827 if (!$schema_enabled) {
3828 wp_send_json_error(['message' => 'Schema markup is not enabled']);
3829 }
3830
3831 // Get schema types data
3832 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every field of every entry is sanitised per-type in sanitize_schema_fields() below before anything is stored.
3833 $schema_types = isset($_POST['schema_types']) ? wp_unslash($_POST['schema_types']) : [];
3834 if (empty($schema_types)) {
3835 wp_send_json_error(['message' => 'No schema types provided']);
3836 }
3837
3838 // Build schema data array
3839 $schema_data = [
3840 'enabled' => true,
3841 'types' => []
3842 ];
3843
3844 foreach ($schema_types as $type_data) {
3845 if (!empty($type_data['type'])) {
3846 $schema_type = sanitize_text_field($type_data['type']);
3847 // The preview collector sends fields: {} for a type whose
3848 // metasync_get_schema_fields request has not landed yet; an
3849 // empty override set would preview as "requires a Headline".
3850 // Complete it with the same placeholders a brand-new type's
3851 // markup carries, mirroring save_schema_markup_data().
3852 $fields = !empty($type_data['fields'])
3853 ? $type_data['fields']
3854 : $this->get_unsubmitted_schema_field_defaults();
3855 $sanitized_fields = $this->sanitize_schema_fields($fields, $schema_type);
3856
3857 $schema_data['types'][] = [
3858 'type' => $schema_type,
3859 'fields' => $sanitized_fields
3860 ];
3861 }
3862 }
3863
3864 // Validate schema requirements before generating preview
3865 $all_validation_errors = [];
3866 foreach ($schema_data['types'] as $schema_type_data) {
3867 $validation_errors = $this->validate_schema_requirements($post_id, $schema_type_data['type'], $schema_type_data['fields']);
3868 if (!empty($validation_errors)) {
3869 // Group errors by schema type with display-friendly names
3870 $schema_type_display_names = [
3871 'article' => 'Article',
3872 'FAQPage' => 'FAQ',
3873 'product' => 'Product',
3874 'recipe' => 'Recipe',
3875 'Event' => 'Event',
3876 'JobPosting' => 'Job Posting',
3877 'Review' => 'Review',
3878 'Course' => 'Course',
3879 'Organization' => 'Organization',
3880 'Person' => 'Person',
3881 'WebSite' => 'Website',
3882 'NewsArticle' => 'News Article',
3883 'LocalBusiness' => 'Local Business',
3884 'HowTo' => 'How-To',
3885 'VideoObject' => 'Video Object'
3886 ];
3887 $schema_type_name = isset($schema_type_display_names[$schema_type_data['type']])
3888 ? $schema_type_display_names[$schema_type_data['type']]
3889 : ucfirst($schema_type_data['type']);
3890
3891 foreach ($validation_errors as $error) {
3892 $all_validation_errors[] = [
3893 'schema_type' => $schema_type_name,
3894 'field' => $error['field'],
3895 'message' => $error['message']
3896 ];
3897 }
3898 }
3899 }
3900
3901 // If there are validation errors, return them instead of generating preview
3902 if (!empty($all_validation_errors)) {
3903 wp_send_json_error([
3904 'message' => 'Validation errors detected',
3905 'validation_errors' => $all_validation_errors
3906 ]);
3907 }
3908
3909 // Generate JSON-LD for all schema types
3910 $all_json_ld = [];
3911 foreach ($schema_data['types'] as $schema_type_data) {
3912 $json_ld = $this->generate_json_ld($post, $schema_type_data);
3913 if ($json_ld) {
3914 $all_json_ld[] = $json_ld;
3915 }
3916 }
3917
3918 if (empty($all_json_ld)) {
3919 wp_send_json_error(['message' => 'Could not generate schema markup']);
3920 }
3921
3922 // Build the final JSON-LD structure with @context and @graph
3923 $final_json_ld = [
3924 '@context' => 'https://schema.org',
3925 '@graph' => $all_json_ld
3926 ];
3927
3928 $formatted_json = metasync_safe_json_ld_encode($final_json_ld, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
3929 if (!is_string($formatted_json) || $formatted_json === '') {
3930 wp_send_json_error(['message' => 'Could not encode schema markup']);
3931 }
3932
3933 // Wrap in script tags for preview
3934 $formatted_json_with_tags = '<script type="application/ld+json" class="metasync-schema">' . "\n" . $formatted_json . "\n" . '</script>';
3935
3936 wp_send_json_success([
3937 'json' => $formatted_json_with_tags,
3938 'types' => array_column($schema_data['types'], 'type')
3939 ]);
3940 }
3941
3942 /**
3943 * Get the plugin name
3944 *
3945 * @return string The plugin name
3946 */
3947 public function get_plugin_name()
3948 {
3949 return $this->plugin_name;
3950 }
3951
3952 /**
3953 * Get the plugin version
3954 *
3955 * @return string The plugin version
3956 */
3957 public function get_version()
3958 {
3959 return $this->version;
3960 }
3961
3962 /**
3963 * Register REST API routes for schema content
3964 */
3965 public function register_schema_content_rest_routes()
3966 {
3967 register_rest_route('metasync/v1', '/schema-content/(?P<post_id>\d+)', [
3968 [
3969 'methods' => 'GET',
3970 'callback' => [$this, 'handle_rest_get_schema_content'],
3971 'permission_callback' => function () {
3972 return current_user_can('edit_posts');
3973 },
3974 'args' => [
3975 'post_id' => [
3976 'required' => true,
3977 'validate_callback' => function ($param) {
3978 return is_numeric($param) && intval($param) > 0;
3979 },
3980 'sanitize_callback' => 'absint',
3981 ],
3982 ],
3983 ],
3984 [
3985 'methods' => 'POST',
3986 'callback' => [$this, 'handle_rest_set_schema_content'],
3987 'permission_callback' => function () {
3988 return current_user_can('edit_posts');
3989 },
3990 'args' => [
3991 'post_id' => [
3992 'required' => true,
3993 'validate_callback' => function ($param) {
3994 return is_numeric($param) && intval($param) > 0;
3995 },
3996 'sanitize_callback' => 'absint',
3997 ],
3998 ],
3999 ],
4000 ]);
4001 }
4002
4003 /**
4004 * Handle GET request for schema content
4005 *
4006 * @param WP_REST_Request $request
4007 * @return WP_REST_Response
4008 */
4009 public function handle_rest_get_schema_content(WP_REST_Request $request)
4010 {
4011 $post_id = $request->get_param('post_id');
4012
4013 $post = get_post($post_id);
4014 if (!$post) {
4015 return new WP_REST_Response(['error' => 'Post not found'], 404);
4016 }
4017
4018 if (!current_user_can('edit_post', $post_id)) {
4019 return new WP_REST_Response(['error' => 'Permission denied'], 403);
4020 }
4021
4022 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
4023
4024 $result = [
4025 'post_id' => $post_id,
4026 'schema_enabled' => false,
4027 'types' => [],
4028 ];
4029
4030 if (!empty($schema_data)) {
4031 $result['schema_enabled'] = isset($schema_data['enabled']) ? (bool) $schema_data['enabled'] : false;
4032 if (!empty($schema_data['types']) && is_array($schema_data['types'])) {
4033 $result['types'] = $schema_data['types'];
4034 }
4035 }
4036
4037 return new WP_REST_Response($result, 200);
4038 }
4039
4040 /**
4041 * Handle POST request to set schema content for a specific type
4042 *
4043 * Expects JSON body: { "schema_type": "FAQPage", "fields": { ... } }
4044 *
4045 * @param WP_REST_Request $request
4046 * @return WP_REST_Response
4047 */
4048 public function handle_rest_set_schema_content(WP_REST_Request $request)
4049 {
4050 $post_id = $request->get_param('post_id');
4051 $schema_type = sanitize_text_field($request->get_param('schema_type'));
4052 $fields = $request->get_param('fields');
4053
4054 if (empty($schema_type)) {
4055 return new WP_REST_Response(['error' => 'schema_type is required'], 400);
4056 }
4057
4058 if (!in_array($schema_type, self::$supported_content_types, true)) {
4059 return new WP_REST_Response(['error' => 'Unsupported schema_type: ' . $schema_type], 400);
4060 }
4061
4062 $post = get_post($post_id);
4063 if (!$post) {
4064 return new WP_REST_Response(['error' => 'Post not found'], 404);
4065 }
4066
4067 if (!current_user_can('edit_post', $post_id)) {
4068 return new WP_REST_Response(['error' => 'Permission denied'], 403);
4069 }
4070
4071 // Sanitize fields before saving (same as Classic Editor path)
4072 $fields = $this->sanitize_schema_fields(is_array($fields) ? $fields : [], $schema_type);
4073
4074 // Get existing schema data
4075 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
4076 if (empty($schema_data)) {
4077 $schema_data = ['enabled' => true, 'types' => []];
4078 }
4079
4080 // Find and update or add the schema type
4081 $found = false;
4082 foreach ($schema_data['types'] as &$type_data) {
4083 if ($type_data['type'] === $schema_type) {
4084 $type_data['fields'] = $fields;
4085 $found = true;
4086 break;
4087 }
4088 }
4089 unset($type_data);
4090
4091 if (!$found) {
4092 $schema_data['types'][] = [
4093 'type' => $schema_type,
4094 'fields' => $fields,
4095 ];
4096 }
4097
4098 // Save updated schema data
4099 update_post_meta($post_id, 'metasync_schema_markup', $schema_data);
4100
4101 // Validate
4102 $validation_warnings = $this->validate_schema_requirements($post_id, $schema_type, $fields);
4103
4104 // Update validation errors in post meta
4105 if (empty($validation_warnings)) {
4106 delete_post_meta($post_id, '_metasync_schema_validation_errors');
4107 } else {
4108 update_post_meta($post_id, '_metasync_schema_validation_errors', $validation_warnings);
4109 }
4110
4111 // Cross-plugin sync
4112 $this->sync_schema_to_seo_plugins($post_id, $schema_type, $fields);
4113
4114 return new WP_REST_Response([
4115 'post_id' => $post_id,
4116 'schema_type' => $schema_type,
4117 'message' => 'Schema content updated successfully',
4118 'validation_warnings' => $validation_warnings,
4119 ], 200);
4120 }
4121
4122 /**
4123 * Sync schema data to third-party SEO plugins (Yoast, Rank Math)
4124 *
4125 * @param int $post_id
4126 * @param string $type Schema type
4127 * @param array $fields Schema fields
4128 */
4129 public function sync_schema_to_seo_plugins($post_id, $type, $fields)
4130 {
4131 // Sync to Yoast SEO
4132 if (defined('WPSEO_VERSION')) {
4133 try {
4134 $this->sync_schema_to_yoast($post_id, $type, $fields);
4135 } catch (\Exception $e) {
4136 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
4137 }
4138 }
4139
4140 // Sync to Rank Math
4141 if (defined('RANK_MATH_VERSION')) {
4142 try {
4143 $this->sync_schema_to_rank_math($post_id, $type, $fields);
4144 } catch (\Exception $e) {
4145 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
4146 }
4147 }
4148 }
4149
4150 /**
4151 * Build a schema.org-compatible block array for a given type and fields.
4152 * Used by both Yoast and Rank Math sync methods to avoid duplicating
4153 * the field-mapping logic.
4154 *
4155 * @param int $post_id
4156 * @param string $type
4157 * @param array $fields
4158 * @return array|null Schema block array, or null if required fields are missing.
4159 */
4160 private function build_schema_block($post_id, $type, $fields)
4161 {
4162 switch ($type) {
4163 case 'FAQPage':
4164 if (empty($fields['faq_items']) || !is_array($fields['faq_items'])) {
4165 return null;
4166 }
4167 $main_entity = [];
4168 foreach ($fields['faq_items'] as $item) {
4169 if (!empty($item['question'])) {
4170 $main_entity[] = [
4171 '@type' => 'Question',
4172 'name' => $item['question'],
4173 'acceptedAnswer' => [
4174 '@type' => 'Answer',
4175 'text' => isset($item['answer']) ? $item['answer'] : '',
4176 ],
4177 ];
4178 }
4179 }
4180 if (empty($main_entity)) {
4181 return null;
4182 }
4183 return [
4184 '@type' => 'FAQPage',
4185 'mainEntity' => $main_entity,
4186 ];
4187
4188 case 'HowTo':
4189 if (empty($fields['steps']) || !is_array($fields['steps'])) {
4190 return null;
4191 }
4192 $steps = [];
4193 foreach ($fields['steps'] as $i => $step) {
4194 if (!empty($step['instructions'])) {
4195 $step_data = [
4196 '@type' => 'HowToStep',
4197 'position' => $i + 1,
4198 'text' => $step['instructions'],
4199 ];
4200 if (!empty($step['image'])) {
4201 $step_data['image'] = $step['image'];
4202 }
4203 $steps[] = $step_data;
4204 }
4205 }
4206 if (empty($steps)) {
4207 return null;
4208 }
4209 $block = [
4210 '@type' => 'HowTo',
4211 'name' => get_the_title($post_id),
4212 'step' => $steps,
4213 ];
4214 if (!empty($fields['total_time'])) {
4215 $block['totalTime'] = 'PT' . intval($fields['total_time']) . 'M';
4216 }
4217 return $block;
4218
4219 case 'product':
4220 if (empty($fields['price']) || floatval($fields['price']) <= 0) {
4221 return null;
4222 }
4223 $block = [
4224 '@type' => 'Product',
4225 'name' => get_the_title($post_id),
4226 'offers' => [
4227 '@type' => 'Offer',
4228 'price' => floatval($fields['price']),
4229 'priceCurrency' => !empty($fields['currency']) ? $fields['currency'] : 'USD',
4230 'availability' => !empty($fields['availability'])
4231 ? 'https://schema.org/' . $fields['availability']
4232 : 'https://schema.org/InStock',
4233 ],
4234 ];
4235 if (!empty($fields['sku'])) {
4236 $block['sku'] = $fields['sku'];
4237 }
4238 if (!empty($fields['brand'])) {
4239 $block['brand'] = ['@type' => 'Brand', 'name' => $fields['brand']];
4240 }
4241 return $block;
4242
4243 case 'recipe':
4244 if (empty($fields['ingredients']) && empty($fields['instructions'])) {
4245 return null;
4246 }
4247 $block = [
4248 '@type' => 'Recipe',
4249 'name' => get_the_title($post_id),
4250 ];
4251 if (!empty($fields['ingredients']) && is_array($fields['ingredients'])) {
4252 $block['recipeIngredient'] = $fields['ingredients'];
4253 }
4254 if (!empty($fields['instructions']) && is_array($fields['instructions'])) {
4255 $inst_steps = [];
4256 foreach ($fields['instructions'] as $inst) {
4257 $text = is_array($inst) ? (isset($inst['text']) ? $inst['text'] : '') : $inst;
4258 if (!empty($text)) {
4259 $inst_steps[] = ['@type' => 'HowToStep', 'text' => $text];
4260 }
4261 }
4262 $block['recipeInstructions'] = $inst_steps;
4263 }
4264 if (!empty($fields['yield'])) {
4265 $block['recipeYield'] = $fields['yield'];
4266 }
4267 if (!empty($fields['prep_time'])) {
4268 $block['prepTime'] = 'PT' . intval($fields['prep_time']) . 'M';
4269 }
4270 if (!empty($fields['cook_time'])) {
4271 $block['cookTime'] = 'PT' . intval($fields['cook_time']) . 'M';
4272 }
4273 if (!empty($fields['total_time'])) {
4274 $block['totalTime'] = 'PT' . intval($fields['total_time']) . 'M';
4275 }
4276 if (!empty($fields['calories'])) {
4277 $block['nutrition'] = ['@type' => 'NutritionInformation', 'calories' => $fields['calories']];
4278 }
4279 return $block;
4280 }
4281
4282 return null;
4283 }
4284
4285 /**
4286 * Sync schema to Yoast SEO
4287 *
4288 * @param int $post_id
4289 * @param string $type
4290 * @param array $fields
4291 */
4292 private function sync_schema_to_yoast($post_id, $type, $fields)
4293 {
4294 $schema_block = $this->build_schema_block($post_id, $type, $fields);
4295 if ($schema_block === null) {
4296 return;
4297 }
4298
4299 // Yoast stores its custom schema blocks as a JSON-encoded array in this meta key.
4300 // We replace or append our block within that array.
4301 $yoast_schema = get_post_meta($post_id, '_yoast_wpseo_schema', true);
4302 if (is_string($yoast_schema) && !empty($yoast_schema)) {
4303 $decoded = json_decode($yoast_schema, true);
4304 $yoast_schema = is_array($decoded) ? $decoded : [];
4305 }
4306 if (!is_array($yoast_schema)) {
4307 $yoast_schema = [];
4308 }
4309
4310 $replaced = false;
4311 foreach ($yoast_schema as &$existing) {
4312 if (isset($existing['@type']) && $existing['@type'] === $schema_block['@type']) {
4313 $existing = $schema_block;
4314 $replaced = true;
4315 break;
4316 }
4317 }
4318 unset($existing);
4319
4320 if (!$replaced) {
4321 $yoast_schema[] = $schema_block;
4322 }
4323
4324 $encoded = wp_json_encode($yoast_schema);
4325
4326 // Consent gate and write-once backup — Yoast's schema meta belongs to
4327 // Yoast, so overwriting it asks the same permission every other
4328 // third-party write does and preserves the block that was there before.
4329 if (class_exists('Metasync_Seo_Backup')) {
4330 Metasync_Seo_Backup::write_post_meta($post_id, '_yoast_wpseo_schema', $encoded);
4331 }
4332 }
4333
4334 /**
4335 * Sync schema to Rank Math
4336 *
4337 * @param int $post_id
4338 * @param string $type
4339 * @param array $fields
4340 */
4341 private function sync_schema_to_rank_math($post_id, $type, $fields)
4342 {
4343 $rm_data = $this->build_schema_block($post_id, $type, $fields);
4344 if ($rm_data === null) {
4345 return;
4346 }
4347
4348 $meta_key = 'rank_math_schema_' . $rm_data['@type'];
4349
4350 // Consent gate and write-once backup, as for Yoast above.
4351 if (class_exists('Metasync_Seo_Backup')) {
4352 Metasync_Seo_Backup::write_post_meta($post_id, $meta_key, $rm_data);
4353 }
4354 }
4355
4356 /**
4357 * Get images for schema markup
4358 * Currently returns featured image, but designed to support multiple sources in future
4359 *
4360 * @param int $post_id The post ID
4361 * @return array Array of image URLs
4362 */
4363 private function get_post_images($post_id)
4364 {
4365 $images = [];
4366
4367 // 1. Featured Image (Post Thumbnail)
4368 $featured_image_id = get_post_thumbnail_id($post_id);
4369 if ($featured_image_id) {
4370 $featured_image_url = wp_get_attachment_image_url($featured_image_id, 'full');
4371 if ($featured_image_url) {
4372 $images[] = $featured_image_url;
4373 }
4374 }
4375
4376 // Future expansion points:
4377 // 2. Gallery images from post content
4378 // 3. Images from post attachments
4379 // 4. WooCommerce product gallery (if WooCommerce is active)
4380 // 5. Custom field images
4381 // 6. Images from post content (img tags)
4382
4383 return $images;
4384 }
4385
4386 }
4387