PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.26
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.26
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 2.6.4 2.6.3 All 139 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.6.26, at schema-markup/class-metasync-schema-markup.php

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