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

1,671 lines 73.4 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 <support@searchatlas.com>
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 public function __construct($plugin_name, $version)
25 {
26 $this->plugin_name = $plugin_name;
27 $this->version = $version;
28 $this->common = new Metasync_Common();
29
30 // Initialize hooks
31 add_action('admin_init', [$this, 'add_schema_markup_meta_box']);
32 add_action('save_post', [$this, 'save_schema_markup_data']);
33 add_action('wp_head', [$this, 'output_schema_markup']);
34 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
35 add_action('admin_notices', [$this, 'display_schema_validation_notices']);
36 }
37
38 /**
39 * Add the Schema Markup meta box to post and page editors
40 */
41 public function add_schema_markup_meta_box()
42 {
43 // Don't show metabox if user's role doesn't have plugin access
44 if (!Metasync::current_user_has_plugin_access()) {
45 return;
46 }
47
48 // Don't show metabox if disabled in Post/Page Editor Settings
49 $general_settings = Metasync::get_option('general', []);
50 if (!empty($general_settings['disable_schema_markup_metabox'])) {
51 return;
52 }
53
54 $plugin_name = Metasync::get_effective_plugin_name();
55
56 add_meta_box(
57 'metasync-schema-markup',
58 "Schema Markup by $plugin_name",
59 [$this, 'schema_markup_meta_box_display'],
60 ['post', 'page'],
61 'normal',
62 'default'
63 );
64 }
65
66 /**
67 * Display the Schema Markup meta box
68 */
69 public function schema_markup_meta_box_display($post)
70 {
71 // Get existing schema data
72 $schema_data = get_post_meta($post->ID, 'metasync_schema_markup', true);
73 $schema_enabled = isset($schema_data['enabled']) ? $schema_data['enabled'] : false;
74 $schema_types = isset($schema_data['types']) ? $schema_data['types'] : [];
75
76 // Get validation errors if any
77 $validation_errors = get_post_meta($post->ID, '_metasync_schema_validation_errors', true);
78 $has_errors = !empty($validation_errors) && is_array($validation_errors);
79
80 // Schema is always enabled by default
81 $global_schema_enabled = true;
82
83 // Add nonce for security
84 wp_nonce_field('metasync_schema_markup_nonce', 'metasync_schema_markup_nonce');
85
86 ?>
87 <div class="metasync-schema-markup-container">
88
89 <?php if (!$global_schema_enabled): ?>
90 <div class="metasync-schema-global-disabled-notice" style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 15px 0; border-radius: 4px;">
91 <p style="margin: 0 0 8px 0; font-weight: 600; color: #856404;">
92 <span class="dashicons dashicons-info" style="color: #ffc107; vertical-align: middle;"></span>
93 Global Schema Markup is Disabled
94 </p>
95 <p style="margin: 0; color: #856404; font-size: 13px;">
96 Schema markup is disabled globally. Please enable it in
97 <a href="<?php echo admin_url('admin.php?page=searchatlas'); ?>" target="_blank">General Configuration Enable Schema</a>
98 for schema markup to be output on the frontend.
99 </p>
100 </div>
101 <?php endif; ?>
102 <p>
103 <label>
104 <input type="checkbox" name="schema_markup[enabled]" value="1" <?php checked($schema_enabled, true); ?>>
105 Enable Schema Markup for this post/page
106 </label>
107 </p>
108
109 <?php if ($schema_enabled && $has_errors): ?>
110 <div class="metasync-schema-validation-warning" style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 15px 0; border-radius: 4px;">
111 <p style="margin: 0 0 8px 0; font-weight: 600; color: #856404;">
112 <span class="dashicons dashicons-warning" style="color: #ffc107;"></span>
113 Validation Issues Detected
114 </p>
115 <ul style="margin: 0; padding-left: 20px; color: #856404;">
116 <?php foreach ($validation_errors as $error): ?>
117 <li><?php echo wp_kses_post($error['message']); ?></li>
118 <?php endforeach; ?>
119 </ul>
120 </div>
121 <?php endif; ?>
122
123 <div class="schema-types-container" style="<?php echo $schema_enabled ? '' : 'display: none;'; ?>">
124 <div class="schema-types-header" style="margin-bottom: 20px;">
125 <h4 style="margin: 0 0 10px 0;">Schema Types</h4>
126 <p class="description" style="margin: 0;">Add multiple schema types to enhance your content's search visibility.</p>
127 </div>
128
129 <div id="schema-types-list">
130 <?php if (!empty($schema_types)): ?>
131 <?php foreach ($schema_types as $index => $schema_type_data): ?>
132 <?php $this->render_schema_type_item($index, $schema_type_data); ?>
133 <?php endforeach; ?>
134 <?php else: ?>
135 <div class="no-schema-types" style="text-align: center; padding: 20px; background: #f9f9f9; border: 2px dashed #ddd; border-radius: 4px; color: #666;">
136 <p style="margin: 0;">No schema types added yet. Click "Add Schema Type" to get started.</p>
137 </div>
138 <?php endif; ?>
139 </div>
140
141 <div class="add-schema-type-section" style="margin-top: 20px;">
142 <button type="button" class="button button-primary" id="add_schema_type_button">
143 <span class="dashicons dashicons-plus" style="vertical-align: middle;"></span> Add Schema Type
144 </button>
145 </div>
146
147 <div class="schema-preview-section" style="margin-top: 20px;">
148 <button type="button" class="button button-secondary" id="preview_schema_button">
149 <span class="dashicons dashicons-visibility" style="vertical-align: middle;"></span> Preview Schema
150 </button>
151 <button type="button" class="button button-secondary" id="copy_schema_button" style="display: none;">
152 <span class="dashicons dashicons-clipboard" style="vertical-align: middle;"></span> Copy to Clipboard
153 </button>
154 </div>
155
156 <div id="schema-preview-output" style="display: none; margin-top: 15px; padding: 15px; background: #f9f9f9; border: 1px solid #ddd; border-radius: 4px;">
157 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
158 <h4 style="margin: 0;">JSON-LD Preview:</h4>
159 <button type="button" class="button-link" id="close_preview" style="color: #dc3232; text-decoration: none;">
160 <span class="dashicons dashicons-no-alt"></span> Close
161 </button>
162 </div>
163 <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>
164 </div>
165 </div>
166 </div>
167 <?php
168 }
169
170 /**
171 * Render individual schema type item
172 */
173 private function render_schema_type_item($index, $schema_type_data)
174 {
175 $schema_type = isset($schema_type_data['type']) ? $schema_type_data['type'] : '';
176 $schema_fields = isset($schema_type_data['fields']) ? $schema_type_data['fields'] : [];
177 $schema_type_names = [
178 'article' => 'Article',
179 'FAQPage' => 'FAQ',
180 'product' => 'Product',
181 'recipe' => 'Recipe'
182 ];
183 $display_name = isset($schema_type_names[$schema_type]) ? $schema_type_names[$schema_type] : ucfirst($schema_type);
184
185 ?>
186 <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;">
187 <div class="schema-type-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
188 <h5 style="margin: 0; color: #333;">
189 <span class="dashicons dashicons-tag" style="color: #0073aa;"></span>
190 <?php echo esc_html($display_name); ?> Schema
191 </h5>
192 <button type="button" class="button button-link remove-schema-type" style="color: #dc3232; text-decoration: none;">
193 <span class="dashicons dashicons-trash"></span> Remove
194 </button>
195 </div>
196
197 <div class="schema-type-content">
198 <input type="hidden" name="schema_markup[types][<?php echo esc_attr($index); ?>][type]" value="<?php echo esc_attr($schema_type); ?>">
199
200 <div class="schema-fields-container">
201 <?php $this->render_schema_fields($schema_type, $schema_fields, $index); ?>
202 </div>
203 </div>
204 </div>
205 <?php
206 }
207
208 /**
209 * Render schema fields based on type
210 */
211 private function render_schema_fields($schema_type, $schema_fields, $index = 0)
212 {
213 switch ($schema_type) {
214 case 'article':
215 $this->render_article_fields($schema_fields, $index);
216 break;
217 case 'FAQPage':
218 $this->render_faq_fields($schema_fields, $index);
219 break;
220 case 'product':
221 $this->render_product_fields($schema_fields, $index);
222 break;
223 case 'recipe':
224 $this->render_recipe_fields($schema_fields, $index);
225 break;
226 default:
227 echo '<p>Select a schema type to see available fields.</p>';
228 break;
229 }
230 }
231
232 /**
233 * Get default values for schema fields (title, description, image)
234 *
235 * @param int $post_id Post ID
236 * @return array Array with 'title', 'description', 'image' keys
237 */
238 private function get_default_schema_values($post_id)
239 {
240 $defaults = [
241 'title' => get_the_title($post_id),
242 'description' => '',
243 'image' => ''
244 ];
245
246 // Get description: excerpt first, then trimmed content
247 $post_excerpt = get_the_excerpt($post_id);
248 if (!empty($post_excerpt)) {
249 $defaults['description'] = $post_excerpt;
250 } else {
251 $post_content = get_post_field('post_content', $post_id);
252 if (!empty($post_content)) {
253 $defaults['description'] = wp_trim_words($post_content, 30);
254 }
255 }
256
257 // Get featured image
258 $featured_image_id = get_post_thumbnail_id($post_id);
259 if ($featured_image_id) {
260 $featured_image_url = wp_get_attachment_image_url($featured_image_id, 'full');
261 if ($featured_image_url) {
262 $defaults['image'] = $featured_image_url;
263 }
264 }
265
266 return $defaults;
267 }
268
269 /**
270 * Render override default fields section
271 *
272 * @param array $fields Schema fields
273 * @param int $index Schema type index
274 * @param string $schema_type Schema type (article, product, recipe)
275 * @param bool $include_image Whether to include image override field
276 */
277 private function render_override_fields_section($fields, $index, $schema_type, $include_image = true)
278 {
279 global $post;
280 $post_id = $post ? $post->ID : 0;
281
282 if (!$post_id) {
283 return;
284 }
285
286 $defaults = $this->get_default_schema_values($post_id);
287
288 // Get override values - only populate with placeholders for NEW schemas (keys don't exist)
289 // If keys exist but are empty, respect the user's choice to leave them blank
290 if (!array_key_exists('title_override', $fields)) {
291 // New schema - populate with placeholder
292 $title_override = '{{post_title}}';
293 } else {
294 // Existing schema - respect saved value (even if blank)
295 $title_override = $fields['title_override'];
296 }
297
298 if (!array_key_exists('description_override', $fields)) {
299 // New schema - populate with placeholder
300 $description_override = '{{post_description}}';
301 } else {
302 // Existing schema - respect saved value (even if blank)
303 $description_override = $fields['description_override'];
304 }
305
306 if (!array_key_exists('image_override', $fields)) {
307 // New schema - populate with placeholder
308 $image_override = '{{featured_image}}';
309 } else {
310 // Existing schema - respect saved value (even if blank)
311 $image_override = $fields['image_override'];
312 }
313
314 // Use override value for image preview if provided and not a placeholder, otherwise use default
315 $image_placeholder = ($image_override && !$this->is_placeholder($image_override)) ? $image_override : $defaults['image'];
316
317 // Check if any overrides are active (not empty and not placeholders)
318 $has_overrides = (!empty($title_override) && !$this->is_placeholder($title_override)) ||
319 (!empty($description_override) && !$this->is_placeholder($description_override)) ||
320 (!empty($image_override) && !$this->is_placeholder($image_override));
321
322 ?>
323 <div class="schema-override-fields-section" style="margin-bottom: 20px;">
324 <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; ?>">
325 <div style="display: flex; justify-content: space-between; align-items: center;">
326 <h4 style="margin: 0; font-size: 14px; font-weight: 600; color: #333;">
327 <span class="dashicons dashicons-edit" style="color: #0073aa; vertical-align: middle;"></span>
328 Override Default Fields
329 <?php if ($has_overrides): ?>
330 <span class="override-indicator" style="background: #0073aa; color: white; padding: 2px 8px; border-radius: 3px; font-size: 11px; margin-left: 8px;">Active</span>
331 <?php endif; ?>
332 </h4>
333 <span class="dashicons dashicons-arrow-down-alt2 toggle-icon" style="color: #666; transition: transform 0.3s;"></span>
334 </div>
335 </div>
336
337 <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;">
338 <p class="description" style="margin: 0 0 15px 0; color: #000000; font-size: 13px;">
339 These fields are required and will be used in schema markup.
340 </p>
341
342 <div class="schema-field" style="margin-bottom: 15px;">
343 <label for="override_title_<?php echo $index; ?>" style="display: block; font-weight: 600; margin-bottom: 5px; color: #333;">
344 Title: <span style="color: #dc3232;">*</span>
345 </label>
346 <div style="display: flex; gap: 8px; align-items: center;">
347 <input type="text"
348 name="schema_markup[types][<?php echo $index; ?>][fields][title_override]"
349 id="override_title_<?php echo $index; ?>"
350 value="<?php echo esc_attr($title_override); ?>"
351 data-default-value="{{post_title}}"
352 style="flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 3px; font-size: 14px;">
353 <button type="button" class="button reset-override-field" data-field="override_title_<?php echo $index; ?>" style="flex-shrink: 0;">
354 Reset
355 </button>
356 </div>
357 <p class="description" style="margin: 5px 0 0 0; color: #000000; font-size: 12px;">
358 Default: {{post_title}} (<?php echo esc_html(wp_trim_words($defaults['title'], 10)); ?>)
359 </p>
360 </div>
361
362 <div class="schema-field" style="margin-bottom: 15px;">
363 <label for="override_description_<?php echo $index; ?>" style="display: block; font-weight: 600; margin-bottom: 5px; color: #333;">
364 Description: <span style="color: #dc3232;">*</span>
365 </label>
366 <div style="display: flex; gap: 8px; align-items: flex-start;">
367 <textarea name="schema_markup[types][<?php echo $index; ?>][fields][description_override]"
368 id="override_description_<?php echo $index; ?>"
369 data-default-value="{{post_description}}"
370 rows="4"
371 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>
372 <button type="button" class="button reset-override-field" data-field="override_description_<?php echo $index; ?>" style="flex-shrink: 0; margin-top: 0;">
373 Reset
374 </button>
375 </div>
376 <p class="description" style="margin: 5px 0 0 0; color: #000000; font-size: 12px;">
377 Default: {{post_description}} (<?php echo esc_html(wp_trim_words($defaults['description'], 20)); ?>)
378 </p>
379 </div>
380
381 <?php if ($include_image): ?>
382 <div class="schema-field" style="margin-bottom: 15px;">
383 <label for="override_image_<?php echo $index; ?>" style="display: block; font-weight: 600; margin-bottom: 5px; color: #333;">
384 Image: <span style="color: #dc3232;">*</span>
385 </label>
386 <div class="image-upload-container" style="margin-bottom: 10px;">
387 <div style="display: flex; gap: 8px; align-items: center; margin-bottom: 10px;">
388 <input type="text"
389 name="schema_markup[types][<?php echo $index; ?>][fields][image_override]"
390 id="override_image_<?php echo $index; ?>"
391 value="<?php echo esc_attr($image_override); ?>"
392 data-default-value="{{featured_image}}"
393 style="flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 3px; font-size: 14px;">
394 <button type="button" class="button upload-image-button" data-target="<?php echo $index; ?>" data-field-id="override_image_<?php echo $index; ?>">
395 Upload Image
396 </button>
397 <button type="button" class="button reset-override-field" data-field="override_image_<?php echo $index; ?>" style="flex-shrink: 0;">
398 Reset
399 </button>
400 </div>
401 <?php if ($image_placeholder): ?>
402 <div class="image-preview" id="override_image_preview_<?php echo $index; ?>" style="margin-top: 10px;">
403 <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;">
404 </div>
405 <?php else: ?>
406 <div class="image-preview" id="override_image_preview_<?php echo $index; ?>" style="display: none; margin-top: 10px;">
407 <img src="" alt="Image Preview" style="max-width: 200px; max-height: 150px; border: 1px solid #ddd; padding: 5px; border-radius: 3px;">
408 </div>
409 <?php endif; ?>
410 </div>
411 <p class="description" style="margin: 5px 0 0 0; color: #000000; font-size: 12px;">
412 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; ?>
413 </p>
414 </div>
415 <?php endif; ?>
416 </div>
417 </div>
418 <?php
419 }
420
421 /**
422 * Render Article schema fields
423 */
424 private function render_article_fields($fields, $index = 0)
425 {
426 $organization_name = isset($fields['organization_name']) ? $fields['organization_name'] : '';
427 $organization_logo = isset($fields['organization_logo']) ? $fields['organization_logo'] : '';
428
429 ?>
430 <div class="schema-field-group">
431 <?php $this->render_override_fields_section($fields, $index, 'article', true); ?>
432
433 <h4>Article Information</h4>
434 <p class="description" style="margin-top: 0;">Optional fields to enhance your article schema.</p>
435
436 <div class="schema-field">
437 <label for="article_organization_name_<?php echo $index; ?>">Organization Name:</label>
438 <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">
439 <p class="description">The name of the organization that published this article.</p>
440 </div>
441
442 <div class="schema-field">
443 <label for="article_organization_logo_<?php echo $index; ?>">Organization Logo:</label>
444 <div class="logo-upload-container">
445 <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;">
446 <button type="button" class="button upload-logo-button" data-target="<?php echo $index; ?>">Upload Logo</button>
447 <button type="button" class="button remove-logo-button" data-target="<?php echo $index; ?>" style="<?php echo empty($organization_logo) ? 'display: none;' : ''; ?>">Remove</button>
448 </div>
449 <div class="logo-preview" id="logo_preview_<?php echo $index; ?>" style="<?php echo empty($organization_logo) ? 'display: none;' : ''; ?>">
450 <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;">
451 </div>
452 <p class="description">Logo of the organization (recommended for better rich results).</p>
453 </div>
454 </div>
455 <?php
456 }
457
458 /**
459 * Render FAQ schema fields
460 */
461 private function render_faq_fields($fields, $index = 0)
462 {
463 $faq_items = isset($fields['faq_items']) ? $fields['faq_items'] : [['question' => '', 'answer' => '']];
464
465 ?>
466 <div class="schema-field-group">
467 <h4>FAQ Questions & Answers</h4>
468 <p class="description">Add questions and answers for your FAQ page. Both fields are required for each FAQ item.</p>
469
470 <div class="faq-items-list">
471 <?php foreach ($faq_items as $faq_index => $item): ?>
472 <div class="faq-item" style="margin: 15px 0; padding: 15px; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9;">
473 <div class="schema-field">
474 <label>Question <?php echo $faq_index + 1; ?>: <span style="color: #dc3232;">*</span></label>
475 <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;">
476 </div>
477 <div class="schema-field">
478 <label>Answer <?php echo $faq_index + 1; ?>: <span style="color: #dc3232;">*</span></label>
479 <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>
480 </div>
481 <button type="button" class="button remove-faq-item" style="background: #dc3232; color: white; border-color: #dc3232;">Remove Question</button>
482 </div>
483 <?php endforeach; ?>
484 </div>
485
486 <button type="button" class="button add-faq-item" style="margin-top: 10px;">Add Question</button>
487 </div>
488 <?php
489 }
490
491 /**
492 * Render Product schema fields
493 */
494 private function render_product_fields($fields, $index = 0)
495 {
496 $sku = isset($fields['sku']) ? $fields['sku'] : '';
497 $brand = isset($fields['brand']) ? $fields['brand'] : '';
498 $price = isset($fields['price']) ? $fields['price'] : '';
499 $currency = isset($fields['currency']) ? $fields['currency'] : 'USD';
500 $availability = isset($fields['availability']) ? $fields['availability'] : 'InStock';
501 $condition = isset($fields['condition']) ? $fields['condition'] : 'NewCondition';
502
503 ?>
504 <div class="schema-field-group">
505 <?php $this->render_override_fields_section($fields, $index, 'product', true); ?>
506
507 <h4>Product Information</h4>
508 <p class="description" style="margin-top: 0;">Fill in the product details below. Fields marked with * are required.</p>
509
510 <div class="schema-field">
511 <label for="product_sku_<?php echo $index; ?>">SKU:</label>
512 <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">
513 </div>
514
515 <div class="schema-field">
516 <label for="product_brand_<?php echo $index; ?>">Brand:</label>
517 <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">
518 </div>
519
520 <div class="schema-field">
521 <label for="product_price_<?php echo $index; ?>">Price: <span style="color: #dc3232;">*</span></label>
522 <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">
523 <p class="description">Required: Enter the product price (must be greater than 0).</p>
524 </div>
525
526 <div class="schema-field">
527 <label for="product_currency_<?php echo $index; ?>">Currency:</label>
528 <select name="schema_markup[types][<?php echo $index; ?>][fields][currency]" id="product_currency_<?php echo $index; ?>">
529 <option value="USD" <?php selected($currency, 'USD'); ?>>USD</option>
530 <option value="EUR" <?php selected($currency, 'EUR'); ?>>EUR</option>
531 <option value="GBP" <?php selected($currency, 'GBP'); ?>>GBP</option>
532 <option value="CAD" <?php selected($currency, 'CAD'); ?>>CAD</option>
533 <option value="AUD" <?php selected($currency, 'AUD'); ?>>AUD</option>
534 </select>
535 </div>
536
537 <div class="schema-field">
538 <label for="product_availability_<?php echo $index; ?>">Availability:</label>
539 <select name="schema_markup[types][<?php echo $index; ?>][fields][availability]" id="product_availability_<?php echo $index; ?>">
540 <option value="InStock" <?php selected($availability, 'InStock'); ?>>In Stock</option>
541 <option value="OutOfStock" <?php selected($availability, 'OutOfStock'); ?>>Out of Stock</option>
542 <option value="PreOrder" <?php selected($availability, 'PreOrder'); ?>>Pre-Order</option>
543 <option value="LimitedAvailability" <?php selected($availability, 'LimitedAvailability'); ?>>Limited Availability</option>
544 </select>
545 </div>
546
547 <div class="schema-field">
548 <label for="product_condition_<?php echo $index; ?>">Condition:</label>
549 <select name="schema_markup[types][<?php echo $index; ?>][fields][condition]" id="product_condition_<?php echo $index; ?>">
550 <option value="NewCondition" <?php selected($condition, 'NewCondition'); ?>>New</option>
551 <option value="UsedCondition" <?php selected($condition, 'UsedCondition'); ?>>Used</option>
552 <option value="RefurbishedCondition" <?php selected($condition, 'RefurbishedCondition'); ?>>Refurbished</option>
553 <option value="DamagedCondition" <?php selected($condition, 'DamagedCondition'); ?>>Damaged</option>
554 </select>
555 </div>
556 </div>
557 <?php
558 }
559
560 /**
561 * Render Recipe schema fields
562 */
563 private function render_recipe_fields($fields, $index = 0)
564 {
565 $yield = isset($fields['yield']) ? $fields['yield'] : '';
566 $ingredients = isset($fields['ingredients']) ? $fields['ingredients'] : [''];
567 $instructions = isset($fields['instructions']) ? $fields['instructions'] : [''];
568 $prep_time = isset($fields['prep_time']) ? $fields['prep_time'] : '';
569 $cook_time = isset($fields['cook_time']) ? $fields['cook_time'] : '';
570 $total_time = isset($fields['total_time']) ? $fields['total_time'] : '';
571 $calories = isset($fields['calories']) ? $fields['calories'] : '';
572
573 ?>
574 <div class="schema-field-group">
575 <?php $this->render_override_fields_section($fields, $index, 'recipe', true); ?>
576
577 <h4>Recipe Information</h4>
578 <p class="description" style="margin-top: 0;">Optional fields to enhance your recipe schema.</p>
579
580 <div class="schema-field">
581 <label for="recipe_yield_<?php echo $index; ?>">Yield (servings):</label>
582 <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">
583 </div>
584
585 <div class="schema-field">
586 <label>Ingredients:</label>
587 <div class="ingredients-list">
588 <?php foreach ($ingredients as $ingredient): ?>
589 <div class="ingredient-item">
590 <input type="text" name="schema_markup[types][<?php echo $index; ?>][fields][ingredients][]" value="<?php echo esc_attr($ingredient); ?>" placeholder="Enter ingredient">
591 <button type="button" class="remove-item">Remove</button>
592 </div>
593 <?php endforeach; ?>
594 </div>
595 <button type="button" class="add-ingredient">Add Ingredient</button>
596 </div>
597
598 <div class="schema-field">
599 <label>Instructions:</label>
600 <div class="instructions-list">
601 <?php foreach ($instructions as $instruction): ?>
602 <div class="instruction-item">
603 <textarea name="schema_markup[types][<?php echo $index; ?>][fields][instructions][]" placeholder="Enter instruction step"><?php echo esc_textarea($instruction); ?></textarea>
604 <button type="button" class="remove-item">Remove</button>
605 </div>
606 <?php endforeach; ?>
607 </div>
608 <button type="button" class="add-instruction">Add Instruction</button>
609 </div>
610
611 <div class="schema-field">
612 <label for="recipe_prep_time_<?php echo $index; ?>">Prep Time (minutes):</label>
613 <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">
614 </div>
615
616 <div class="schema-field">
617 <label for="recipe_cook_time_<?php echo $index; ?>">Cook Time (minutes):</label>
618 <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">
619 </div>
620
621 <div class="schema-field">
622 <label for="recipe_total_time_<?php echo $index; ?>">Total Time (minutes):</label>
623 <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">
624 </div>
625
626 <div class="schema-field">
627 <label for="recipe_calories_<?php echo $index; ?>">Calories per serving:</label>
628 <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">
629 </div>
630 </div>
631 <?php
632 }
633
634 /**
635 * Save schema markup data
636 */
637 public function save_schema_markup_data($post_id)
638 {
639 // Check if user has permission to edit the post
640 if (!current_user_can('edit_post', $post_id)) {
641 return;
642 }
643
644 // Verify nonce
645 if (!isset($_POST['metasync_schema_markup_nonce']) ||
646 !wp_verify_nonce($_POST['metasync_schema_markup_nonce'], 'metasync_schema_markup_nonce')) {
647 return;
648 }
649
650 // Check if this is an autosave
651 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
652 return;
653 }
654
655 // Get existing schema data to preserve if needed
656 $existing_schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
657
658 // Sanitize and save data
659 $schema_data = [];
660
661 if (isset($_POST['schema_markup'])) {
662 $post_data = $this->common->sanitize_array($_POST['schema_markup']);
663
664 $schema_data['enabled'] = isset($post_data['enabled']) ? (bool)$post_data['enabled'] : false;
665 $schema_data['types'] = [];
666
667 // Process multiple schema types and ensure uniqueness
668 if (isset($post_data['types']) && is_array($post_data['types'])) {
669 $seen_types = [];
670 foreach ($post_data['types'] as $type_data) {
671 if (!empty($type_data['type'])) {
672 $schema_type = sanitize_text_field($type_data['type']);
673
674 // Skip if this schema type already exists (keep first occurrence)
675 if (in_array($schema_type, $seen_types)) {
676 continue;
677 }
678
679 $schema_fields = isset($type_data['fields']) ? $this->sanitize_schema_fields($type_data['fields'], $schema_type) : [];
680
681 $schema_data['types'][] = [
682 'type' => $schema_type,
683 'fields' => $schema_fields
684 ];
685
686 // Mark this type as seen
687 $seen_types[] = $schema_type;
688 }
689 }
690 }
691 }
692
693 // If checkbox is unchecked, preserve existing schema data but mark as disabled
694 if (empty($schema_data['enabled'])) {
695 if (!empty($existing_schema_data) && !empty($existing_schema_data['types'])) {
696 // Preserve existing schema types but mark as disabled
697 $schema_data['enabled'] = false;
698 $schema_data['types'] = $existing_schema_data['types'];
699 }
700 }
701
702 // Delete any existing validation errors
703 delete_post_meta($post_id, '_metasync_schema_validation_errors');
704
705 // Save the meta (only validate if enabled)
706 if (!empty($schema_data['enabled']) && !empty($schema_data['types'])) {
707 // Validate schema requirements for all types
708 $all_validation_errors = [];
709
710 foreach ($schema_data['types'] as $schema_type_data) {
711 $validation_errors = $this->validate_schema_requirements($post_id, $schema_type_data['type'], $schema_type_data['fields']);
712 if (!empty($validation_errors)) {
713 $all_validation_errors = array_merge($all_validation_errors, $validation_errors);
714 }
715 }
716
717 if (!empty($all_validation_errors)) {
718 // Store validation errors as post meta
719 update_post_meta($post_id, '_metasync_schema_validation_errors', $all_validation_errors);
720
721 // Set a transient to show admin notice immediately after save
722 set_transient('metasync_schema_validation_error_' . $post_id, $all_validation_errors, 45);
723 }
724 }
725
726 // Save the schema data
727 update_post_meta($post_id, 'metasync_schema_markup', $schema_data);
728 }
729
730 /**
731 * Validate schema requirements based on type
732 *
733 * @param int $post_id The post ID
734 * @param string $type Schema type
735 * @param array $fields Schema fields
736 * @return array Array of validation errors (empty if valid)
737 */
738 private function validate_schema_requirements($post_id, $type, $fields)
739 {
740 $errors = [];
741
742 switch ($type) {
743 case 'article':
744 $errors = $this->validate_article_schema($post_id, $fields);
745 break;
746 // Add more schema type validations here in the future
747 case 'product':
748 $errors = $this->validate_product_schema($post_id, $fields);
749 break;
750 case 'recipe':
751 $errors = $this->validate_recipe_schema($post_id, $fields);
752 break;
753 case 'FAQPage':
754 $errors = $this->validate_faq_schema($post_id, $fields);
755 break;
756 }
757
758 return $errors;
759 }
760
761 /**
762 * Validate Article schema requirements
763 * Article requires: Headline, Description, and Image
764 *
765 * @param int $post_id The post ID
766 * @param array $fields Schema fields
767 * @return array Array of validation errors
768 */
769 private function validate_article_schema($post_id, $fields)
770 {
771 $errors = [];
772
773 // Get effective values (override if provided, otherwise defaults)
774 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
775 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
776 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
777
778 // 1. Validate Headline (Title)
779 if (empty($effective_title) || trim($effective_title) === '') {
780 $errors[] = [
781 'field' => 'headline',
782 'message' => 'Article schema requires a <strong>Headline</strong>. Please add a post title or override it in the Override Default Fields section.'
783 ];
784 }
785
786 // 2. Validate Description
787 if (empty($effective_description) || trim($effective_description) === '') {
788 $errors[] = [
789 'field' => 'description',
790 'message' => 'Article schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
791 ];
792 }
793
794 // 3. Validate Image
795 if (empty($effective_image)) {
796 $errors[] = [
797 'field' => 'image',
798 '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.'
799 ];
800 }
801
802 return $errors;
803 }
804
805 /**
806 * Validate Product schema requirements
807 * Product requires: Name, Description, Image, and Price
808 *
809 * @param int $post_id The post ID
810 * @param array $fields Schema fields
811 * @return array Array of validation errors
812 */
813 private function validate_product_schema($post_id, $fields)
814 {
815 $errors = [];
816
817 // Get effective values (override if provided, otherwise defaults)
818 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
819 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
820 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
821
822 // 1. Validate Name (Title)
823 if (empty($effective_title) || trim($effective_title) === '') {
824 $errors[] = [
825 'field' => 'name',
826 'message' => 'Product schema requires a <strong>Name</strong>. Please add a post title or override it in the Override Default Fields section.'
827 ];
828 }
829
830 // 2. Validate Description
831 if (empty($effective_description) || trim($effective_description) === '') {
832 $errors[] = [
833 'field' => 'description',
834 'message' => 'Product schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
835 ];
836 }
837
838 // 3. Validate Image
839 if (empty($effective_image)) {
840 $errors[] = [
841 'field' => 'image',
842 '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.'
843 ];
844 }
845
846 // 4. Validate Price
847 if (empty($fields['price']) || floatval($fields['price']) <= 0) {
848 $errors[] = [
849 'field' => 'price',
850 'message' => 'Product schema requires a valid <strong>Price</strong>. Please enter a price greater than 0.'
851 ];
852 }
853
854 return $errors;
855 }
856
857 /**
858 * Validate Recipe schema requirements
859 * Recipe requires: Name, Description, and Image
860 *
861 * @param int $post_id The post ID
862 * @param array $fields Schema fields
863 * @return array Array of validation errors
864 */
865 private function validate_recipe_schema($post_id, $fields)
866 {
867 $errors = [];
868
869 // Get effective values (override if provided, otherwise defaults)
870 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post_id);
871 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post_id);
872 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post_id);
873
874 // 1. Validate Name (Title)
875 if (empty($effective_title) || trim($effective_title) === '') {
876 $errors[] = [
877 'field' => 'name',
878 'message' => 'Recipe schema requires a <strong>Name</strong>. Please add a post title or override it in the Override Default Fields section.'
879 ];
880 }
881
882 // 2. Validate Description
883 if (empty($effective_description) || trim($effective_description) === '') {
884 $errors[] = [
885 'field' => 'description',
886 'message' => 'Recipe schema requires a <strong>Description</strong>. Please add post content, an excerpt, or override it in the Override Default Fields section.'
887 ];
888 }
889
890 // 3. Validate Image
891 if (empty($effective_image)) {
892 $errors[] = [
893 'field' => 'image',
894 '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.'
895 ];
896 }
897
898 return $errors;
899 }
900
901 /**
902 * Validate FAQ schema requirements
903 * FAQ requires at least one Q&A pair with non-empty question and answer
904 *
905 * @param int $post_id The post ID
906 * @param array $fields Schema fields
907 * @return array Array of validation errors
908 */
909 private function validate_faq_schema($post_id, $fields)
910 {
911 $errors = [];
912
913 // Check if FAQ items exist
914 if (empty($fields['faq_items']) || !is_array($fields['faq_items'])) {
915 $errors[] = [
916 'field' => 'faq_items',
917 'message' => 'FAQ schema requires at least one <strong>Question & Answer</strong> pair. Please add at least one FAQ item.'
918 ];
919 return $errors;
920 }
921
922 // Validate each FAQ item
923 $has_valid_item = false;
924 foreach ($fields['faq_items'] as $index => $item) {
925 $question = isset($item['question']) ? trim($item['question']) : '';
926 $answer = isset($item['answer']) ? trim($item['answer']) : '';
927
928 // Check if both question and answer are provided
929 if (!empty($question) && !empty($answer)) {
930 $has_valid_item = true;
931 } elseif (!empty($question) || !empty($answer)) {
932 // Partial entry - one is filled but not the other
933 $item_number = $index + 1;
934 if (empty($question)) {
935 $errors[] = [
936 'field' => 'faq_items',
937 'message' => "FAQ item #{$item_number}: <strong>Question</strong> cannot be left blank."
938 ];
939 }
940 if (empty($answer)) {
941 $errors[] = [
942 'field' => 'faq_items',
943 'message' => "FAQ item #{$item_number}: <strong>Answer</strong> cannot be left blank."
944 ];
945 }
946 }
947 }
948
949 // Check if at least one valid Q&A pair exists
950 if (!$has_valid_item) {
951 $errors[] = [
952 'field' => 'faq_items',
953 '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.'
954 ];
955 }
956
957 return $errors;
958 }
959
960 /**
961 * Display schema validation notices in admin
962 */
963 public function display_schema_validation_notices()
964 {
965 // Only show on post edit screens
966 $screen = get_current_screen();
967 if (!$screen || ($screen->base !== 'post' && $screen->base !== 'post-new')) {
968 return;
969 }
970
971 // Get current post ID
972 global $post;
973 if (!$post || !isset($post->ID)) {
974 return;
975 }
976
977 $post_id = $post->ID;
978
979 // Check for validation errors from transient (immediately after save)
980 $errors = get_transient('metasync_schema_validation_error_' . $post_id);
981
982 // If no transient, check post meta (for persistent errors)
983 if ($errors === false) {
984 $errors = get_post_meta($post_id, '_metasync_schema_validation_errors', true);
985 } else {
986 // Delete the transient after displaying once
987 delete_transient('metasync_schema_validation_error_' . $post_id);
988 }
989
990 if (empty($errors) || !is_array($errors)) {
991 return;
992 }
993
994 // Get schema type for context
995 $schema_data = get_post_meta($post_id, 'metasync_schema_markup', true);
996 $schema_type = isset($schema_data['type']) ? ucfirst($schema_data['type']) : 'Schema';
997
998 ?>
999 <div class="notice notice-error is-dismissible metasync-schema-validation-notice">
1000 <h3 style="margin: 0.5em 0;">⚠️ <?php echo esc_html($schema_type); ?> Schema Validation Errors</h3>
1001 <p><strong>The following required fields are missing:</strong></p>
1002 <ul style="list-style: disc; margin-left: 20px;">
1003 <?php foreach ($errors as $error): ?>
1004 <li><?php echo wp_kses_post($error['message']); ?></li>
1005 <?php endforeach; ?>
1006 </ul>
1007 <p><em>Please fix these issues to ensure your schema markup is valid and can be properly indexed by search engines.</em></p>
1008 </div>
1009 <?php
1010 }
1011
1012 /**
1013 * Sanitize schema fields based on type
1014 */
1015 private function sanitize_schema_fields($fields, $type)
1016 {
1017 $sanitized = [];
1018
1019 // Sanitize override fields (common to all types that support them)
1020 // Store placeholders as-is in database, will be replaced during JSON generation
1021 $sanitized['title_override'] = isset($fields['title_override']) ? sanitize_text_field($fields['title_override']) : '';
1022 $sanitized['description_override'] = isset($fields['description_override']) ? sanitize_textarea_field($fields['description_override']) : '';
1023
1024 // For image_override, check if it's a placeholder before sanitizing as URL
1025 $image_override_raw = isset($fields['image_override']) ? trim($fields['image_override']) : '';
1026 if ($this->is_placeholder($image_override_raw)) {
1027 // Store placeholder as-is
1028 $sanitized['image_override'] = $image_override_raw;
1029 } else {
1030 // Sanitize as URL only if it's not a placeholder
1031 $sanitized['image_override'] = !empty($image_override_raw) ? esc_url_raw($image_override_raw) : '';
1032 }
1033
1034 switch ($type) {
1035 case 'article':
1036 $sanitized['organization_name'] = isset($fields['organization_name']) ? sanitize_text_field($fields['organization_name']) : '';
1037 $sanitized['organization_logo'] = isset($fields['organization_logo']) ? esc_url_raw($fields['organization_logo']) : '';
1038 break;
1039 case 'FAQPage':
1040 // FAQ doesn't use override fields, so remove them
1041 unset($sanitized['title_override'], $sanitized['description_override'], $sanitized['image_override']);
1042 $sanitized['faq_items'] = [];
1043 if (isset($fields['faq_items']) && is_array($fields['faq_items'])) {
1044 foreach ($fields['faq_items'] as $item) {
1045 if (!empty($item['question']) && !empty($item['answer'])) {
1046 $sanitized['faq_items'][] = [
1047 'question' => sanitize_text_field($item['question']),
1048 'answer' => sanitize_textarea_field($item['answer'])
1049 ];
1050 }
1051 }
1052 }
1053 break;
1054 case 'product':
1055 $sanitized['sku'] = isset($fields['sku']) ? sanitize_text_field($fields['sku']) : '';
1056 $sanitized['brand'] = isset($fields['brand']) ? sanitize_text_field($fields['brand']) : '';
1057 $sanitized['price'] = isset($fields['price']) ? floatval($fields['price']) : 0;
1058 $sanitized['currency'] = isset($fields['currency']) ? sanitize_text_field($fields['currency']) : 'USD';
1059 $sanitized['availability'] = isset($fields['availability']) ? sanitize_text_field($fields['availability']) : 'InStock';
1060 $sanitized['condition'] = isset($fields['condition']) ? sanitize_text_field($fields['condition']) : 'NewCondition';
1061 break;
1062
1063 case 'recipe':
1064 $sanitized['yield'] = isset($fields['yield']) ? sanitize_text_field($fields['yield']) : '';
1065 $sanitized['ingredients'] = isset($fields['ingredients']) ? array_map('sanitize_text_field', array_filter($fields['ingredients'])) : [];
1066 $sanitized['instructions'] = isset($fields['instructions']) ? array_map('sanitize_textarea_field', array_filter($fields['instructions'])) : [];
1067 $sanitized['prep_time'] = isset($fields['prep_time']) ? intval($fields['prep_time']) : 0;
1068 $sanitized['cook_time'] = isset($fields['cook_time']) ? intval($fields['cook_time']) : 0;
1069 $sanitized['total_time'] = isset($fields['total_time']) ? intval($fields['total_time']) : 0;
1070 $sanitized['calories'] = isset($fields['calories']) ? intval($fields['calories']) : 0;
1071 break;
1072 }
1073
1074 return $sanitized;
1075 }
1076
1077 /**
1078 * Output schema markup to the frontend
1079 */
1080 public function output_schema_markup()
1081 {
1082 // Schema is always enabled by default - no check needed
1083
1084 // Only output on single posts/pages
1085 if (!is_singular()) {
1086 return;
1087 }
1088
1089 global $post;
1090 // Check if $post object exists and has an ID
1091 if (!$post || !isset($post->ID)) {
1092 return;
1093 }
1094 $schema_data = get_post_meta($post->ID, 'metasync_schema_markup', true);
1095
1096 if (empty($schema_data) || !$schema_data['enabled'] || empty($schema_data['types'])) {
1097 return;
1098 }
1099
1100 // Check for validation errors - do not output JSON-LD if there are errors
1101 $validation_errors = get_post_meta($post->ID, '_metasync_schema_validation_errors', true);
1102 if (!empty($validation_errors) && is_array($validation_errors)) {
1103 // Don't output schema markup if there are validation errors
1104 return;
1105 }
1106
1107 // Generate JSON-LD for all schema types
1108 $all_json_ld = [];
1109 foreach ($schema_data['types'] as $schema_type_data) {
1110 $json_ld = $this->generate_json_ld($post, $schema_type_data);
1111 if ($json_ld) {
1112 $all_json_ld[] = $json_ld;
1113 }
1114 }
1115
1116 // Output all schemas in a single script tag with @graph pattern
1117 if (!empty($all_json_ld)) {
1118 // Build the final JSON-LD structure with @context and @graph
1119 $final_json_ld = [
1120 '@context' => 'https://schema.org',
1121 '@graph' => $all_json_ld
1122 ];
1123
1124 echo '<script type="application/ld+json" class="metasync-schema">' . "\n";
1125 echo wp_json_encode($final_json_ld, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
1126 echo "\n" . '</script>' . "\n";
1127 }
1128 }
1129
1130 /**
1131 * Replace placeholders with actual dynamic values
1132 *
1133 * @param string $value Value that may contain placeholders
1134 * @param int $post_id Post ID
1135 * @return string Value with placeholders replaced
1136 */
1137 private function replace_placeholders($value, $post_id)
1138 {
1139 if (empty($value)) {
1140 return $value;
1141 }
1142
1143 $defaults = $this->get_default_schema_values($post_id);
1144
1145 // Replace placeholders with actual values
1146 $value = str_replace('{{post_title}}', $defaults['title'], $value);
1147 $value = str_replace('{{post_description}}', $defaults['description'], $value);
1148 $value = str_replace('{{featured_image}}', $defaults['image'], $value);
1149
1150 return $value;
1151 }
1152
1153 /**
1154 * Check if a value is a placeholder (should be treated as empty/default)
1155 *
1156 * @param string $value Value to check
1157 * @return bool True if value is a placeholder
1158 */
1159 private function is_placeholder($value)
1160 {
1161 $placeholders = ['{{post_title}}', '{{post_description}}', '{{featured_image}}'];
1162 return in_array(trim($value), $placeholders, true);
1163 }
1164
1165 /**
1166 * Get effective schema value (override if provided, otherwise default)
1167 * Replaces placeholders with actual dynamic values during JSON generation
1168 *
1169 * @param string $field_name Field name (title_override, description_override, image_override)
1170 * @param array $fields Schema fields array
1171 * @param int $post_id Post ID
1172 * @return string Effective value with placeholders replaced
1173 */
1174 private function get_effective_schema_value($field_name, $fields, $post_id)
1175 {
1176 // Get override value from database
1177 $override_key = $field_name;
1178 $override_value = isset($fields[$override_key]) ? trim($fields[$override_key]) : '';
1179
1180 // If override value is empty (blank field), return empty
1181 // This will trigger validation error - user must either fill it or use placeholder
1182 if (empty($override_value)) {
1183 return '';
1184 }
1185
1186 // If override value is provided (including placeholders), replace placeholders with actual data
1187 return $this->replace_placeholders($override_value, $post_id);
1188 }
1189
1190 /**
1191 * Generate JSON-LD based on schema type
1192 */
1193 private function generate_json_ld($post, $schema_type_data)
1194 {
1195 $type = $schema_type_data['type'];
1196 $fields = $schema_type_data['fields'];
1197
1198 // Build base JSON-LD without @context (will be added at root level)
1199 $json_ld = [
1200 '@type' => ucfirst($type)
1201 ];
1202
1203 // Get effective values (override if provided, otherwise defaults)
1204 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post->ID);
1205 $effective_description = $this->get_effective_schema_value('description_override', $fields, $post->ID);
1206 $effective_image = $this->get_effective_schema_value('image_override', $fields, $post->ID);
1207
1208 // For articles, don't add 'name' and 'url' as we'll use 'headline' and 'mainEntityOfPage' instead
1209 // For FAQ, we only need the schema type and mainEntity
1210 if ($type !== 'article' && $type !== 'FAQPage') {
1211 $json_ld['name'] = $effective_title;
1212 $json_ld['url'] = get_permalink($post->ID);
1213 $json_ld['description'] = $effective_description;
1214
1215 // Add images - use override if provided, otherwise get from featured image
1216 if (!empty($effective_image)) {
1217 $json_ld['image'] = $effective_image;
1218 } else {
1219 $images = $this->get_post_images($post->ID);
1220 if (!empty($images)) {
1221 // Return single image URL if only one, array if multiple
1222 $json_ld['image'] = count($images) === 1 ? $images[0] : $images;
1223 }
1224 }
1225 } elseif ($type === 'article') {
1226 $json_ld['description'] = $effective_description;
1227
1228 // Add images - use override if provided, otherwise get from featured image
1229 if (!empty($effective_image)) {
1230 $json_ld['image'] = $effective_image;
1231 } else {
1232 $images = $this->get_post_images($post->ID);
1233 if (!empty($images)) {
1234 $json_ld['image'] = count($images) === 1 ? $images[0] : $images;
1235 }
1236 }
1237 }
1238
1239 switch ($type) {
1240 case 'article':
1241 $json_ld = array_merge($json_ld, $this->generate_article_json_ld($fields, $post));
1242 break;
1243 case 'FAQPage':
1244 $json_ld = array_merge($json_ld, $this->generate_faq_json_ld($fields));
1245 break;
1246 case 'product':
1247 $json_ld = array_merge($json_ld, $this->generate_product_json_ld($fields));
1248 break;
1249 case 'recipe':
1250 $json_ld = array_merge($json_ld, $this->generate_recipe_json_ld($fields));
1251 break;
1252 }
1253
1254 return $json_ld;
1255 }
1256
1257 /**
1258 * Generate Article JSON-LD
1259 */
1260 private function generate_article_json_ld($fields, $post)
1261 {
1262 $article_data = [];
1263
1264 // Add headline (required by Google) - use override if provided
1265 $effective_title = $this->get_effective_schema_value('title_override', $fields, $post->ID);
1266 $article_data['headline'] = $effective_title;
1267
1268 // Add mainEntityOfPage instead of url (Google's standard for articles)
1269 $article_data['mainEntityOfPage'] = [
1270 '@type' => 'WebPage',
1271 '@id' => get_permalink($post->ID)
1272 ];
1273
1274 // Add datePublished (required by Google)
1275 $article_data['datePublished'] = get_the_date('c', $post->ID);
1276
1277 // Add dateModified (required by Google)
1278 $article_data['dateModified'] = get_the_modified_date('c', $post->ID);
1279
1280 // Add author information
1281 if (!empty($fields['organization_name'])) {
1282 // Organization as author
1283 $article_data['author'] = [
1284 '@type' => 'Organization',
1285 'name' => $fields['organization_name']
1286 ];
1287
1288 // Add organization logo if provided
1289 if (!empty($fields['organization_logo'])) {
1290 $article_data['author']['logo'] = [
1291 '@type' => 'ImageObject',
1292 'url' => $fields['organization_logo']
1293 ];
1294 }
1295 } else {
1296 // Fallback to WordPress post author
1297 $author_id = $post->post_author;
1298 $author_name = get_the_author_meta('display_name', $author_id);
1299 $author_url = get_author_posts_url($author_id);
1300
1301 $article_data['author'] = [
1302 '@type' => 'Person',
1303 'name' => $author_name,
1304 'url' => $author_url
1305 ];
1306 }
1307
1308 // Add publisher information (required by Google)
1309 if (!empty($fields['organization_name'])) {
1310 $article_data['publisher'] = [
1311 '@type' => 'Organization',
1312 'name' => $fields['organization_name']
1313 ];
1314
1315 // Add organization logo if provided (required for publisher)
1316 if (!empty($fields['organization_logo'])) {
1317 $article_data['publisher']['logo'] = [
1318 '@type' => 'ImageObject',
1319 'url' => $fields['organization_logo']
1320 ];
1321 }
1322 }
1323
1324 return $article_data;
1325 }
1326
1327 /**
1328 * Generate FAQ JSON-LD
1329 */
1330 private function generate_faq_json_ld($fields)
1331 {
1332 $faq_data = [];
1333
1334 // Add FAQ mainEntity
1335 if (!empty($fields['faq_items'])) {
1336 $faq_data['mainEntity'] = [];
1337
1338 foreach ($fields['faq_items'] as $item) {
1339 $faq_data['mainEntity'][] = [
1340 '@type' => 'Question',
1341 'name' => $item['question'],
1342 'acceptedAnswer' => [
1343 '@type' => 'Answer',
1344 'text' => $item['answer']
1345 ]
1346 ];
1347 }
1348 }
1349
1350 return $faq_data;
1351 }
1352
1353 /**
1354 * Generate Product JSON-LD
1355 */
1356 private function generate_product_json_ld($fields)
1357 {
1358 $product_data = [];
1359
1360 if (!empty($fields['sku'])) {
1361 $product_data['sku'] = $fields['sku'];
1362 }
1363
1364 if (!empty($fields['brand'])) {
1365 $product_data['brand'] = [
1366 '@type' => 'Brand',
1367 'name' => $fields['brand']
1368 ];
1369 }
1370
1371 if (!empty($fields['price'])) {
1372 $product_data['offers'] = [
1373 '@type' => 'Offer',
1374 'price' => $fields['price'],
1375 'priceCurrency' => $fields['currency'] ?? 'USD',
1376 'availability' => 'https://schema.org/' . ($fields['availability'] ?? 'InStock'),
1377 'itemCondition' => 'https://schema.org/' . ($fields['condition'] ?? 'NewCondition')
1378 ];
1379 }
1380
1381 return $product_data;
1382 }
1383
1384 /**
1385 * Generate Recipe JSON-LD
1386 */
1387 private function generate_recipe_json_ld($fields)
1388 {
1389 $recipe_data = [];
1390
1391 if (!empty($fields['yield'])) {
1392 $recipe_data['recipeYield'] = $fields['yield'];
1393 }
1394
1395 if (!empty($fields['ingredients'])) {
1396 $recipe_data['recipeIngredient'] = $fields['ingredients'];
1397 }
1398
1399 if (!empty($fields['instructions'])) {
1400 $recipe_data['recipeInstructions'] = [];
1401 foreach ($fields['instructions'] as $instruction) {
1402 $recipe_data['recipeInstructions'][] = [
1403 '@type' => 'HowToStep',
1404 'text' => $instruction
1405 ];
1406 }
1407 }
1408
1409 if (!empty($fields['prep_time'])) {
1410 $recipe_data['prepTime'] = 'PT' . $fields['prep_time'] . 'M';
1411 }
1412
1413 if (!empty($fields['cook_time'])) {
1414 $recipe_data['cookTime'] = 'PT' . $fields['cook_time'] . 'M';
1415 }
1416
1417 if (!empty($fields['total_time'])) {
1418 $recipe_data['totalTime'] = 'PT' . $fields['total_time'] . 'M';
1419 }
1420
1421 if (!empty($fields['calories'])) {
1422 $recipe_data['nutrition'] = [
1423 '@type' => 'NutritionInformation',
1424 'calories' => $fields['calories'] . ' calories'
1425 ];
1426 }
1427
1428 return $recipe_data;
1429 }
1430
1431 /**
1432 * Enqueue admin scripts
1433 */
1434 public function enqueue_admin_scripts($hook)
1435 {
1436 // Only load on post/page edit screens
1437 if ($hook !== 'post.php' && $hook !== 'post-new.php') {
1438 return;
1439 }
1440
1441 wp_enqueue_script('jquery');
1442
1443 // Enqueue WordPress media scripts for image uploader
1444 wp_enqueue_media();
1445
1446 // Enqueue schema markup admin CSS
1447 wp_enqueue_style(
1448 'metasync-schema-markup-admin',
1449 plugin_dir_url(__FILE__) . 'css/schema-markup-admin.css',
1450 [],
1451 $this->version,
1452 'all'
1453 );
1454
1455 // Enqueue schema markup admin JS
1456 wp_enqueue_script(
1457 'metasync-schema-markup-admin',
1458 plugin_dir_url(__FILE__) . 'js/schema-markup-admin.js',
1459 ['jquery', 'wp-util'],
1460 $this->version,
1461 true
1462 );
1463
1464 // Localize script with nonces
1465 wp_localize_script(
1466 'metasync-schema-markup-admin',
1467 'metasyncSchemaMarkup',
1468 [
1469 'ajaxurl' => admin_url('admin-ajax.php'),
1470 'schema_fields_nonce' => wp_create_nonce('metasync_schema_fields_nonce'),
1471 'preview_schema_nonce' => wp_create_nonce('metasync_preview_schema_nonce')
1472 ]
1473 );
1474 }
1475
1476 /**
1477 * AJAX handler for getting schema fields
1478 */
1479 public function ajax_get_schema_fields()
1480 {
1481 // Verify nonce
1482 if (!wp_verify_nonce($_POST['nonce'], 'metasync_schema_fields_nonce')) {
1483 wp_die('Security check failed');
1484 }
1485
1486 $schema_type = sanitize_text_field($_POST['schema_type']);
1487 $index = isset($_POST['index']) ? intval($_POST['index']) : 0;
1488 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
1489
1490 // Set global post for override fields rendering
1491 if ($post_id) {
1492 global $post;
1493 $post = get_post($post_id);
1494 }
1495
1496 ob_start();
1497 $this->render_schema_fields($schema_type, [], $index);
1498 $html = ob_get_clean();
1499
1500 wp_send_json_success($html);
1501 }
1502
1503 /**
1504 * AJAX handler for previewing schema markup
1505 */
1506 public function ajax_preview_schema()
1507 {
1508 // Verify nonce
1509 if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_preview_schema_nonce')) {
1510 wp_send_json_error(['message' => 'Security check failed']);
1511 }
1512
1513 // Get post ID
1514 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
1515 if (!$post_id) {
1516 wp_send_json_error(['message' => 'Invalid post ID']);
1517 }
1518
1519 // Get post object
1520 $post = get_post($post_id);
1521 if (!$post) {
1522 wp_send_json_error(['message' => 'Post not found']);
1523 }
1524
1525 // Check if schema is enabled
1526 $schema_enabled = isset($_POST['schema_enabled']) && $_POST['schema_enabled'];
1527 if (!$schema_enabled) {
1528 wp_send_json_error(['message' => 'Schema markup is not enabled']);
1529 }
1530
1531 // Get schema types data
1532 $schema_types = isset($_POST['schema_types']) ? $_POST['schema_types'] : [];
1533 if (empty($schema_types)) {
1534 wp_send_json_error(['message' => 'No schema types provided']);
1535 }
1536
1537 // Build schema data array
1538 $schema_data = [
1539 'enabled' => true,
1540 'types' => []
1541 ];
1542
1543 foreach ($schema_types as $type_data) {
1544 if (!empty($type_data['type'])) {
1545 $schema_type = sanitize_text_field($type_data['type']);
1546 $fields = isset($type_data['fields']) ? $type_data['fields'] : [];
1547 $sanitized_fields = $this->sanitize_schema_fields($fields, $schema_type);
1548
1549 $schema_data['types'][] = [
1550 'type' => $schema_type,
1551 'fields' => $sanitized_fields
1552 ];
1553 }
1554 }
1555
1556 // Validate schema requirements before generating preview
1557 $all_validation_errors = [];
1558 foreach ($schema_data['types'] as $schema_type_data) {
1559 $validation_errors = $this->validate_schema_requirements($post_id, $schema_type_data['type'], $schema_type_data['fields']);
1560 if (!empty($validation_errors)) {
1561 // Group errors by schema type with display-friendly names
1562 $schema_type_display_names = [
1563 'article' => 'Article',
1564 'FAQPage' => 'FAQ',
1565 'product' => 'Product',
1566 'recipe' => 'Recipe'
1567 ];
1568 $schema_type_name = isset($schema_type_display_names[$schema_type_data['type']])
1569 ? $schema_type_display_names[$schema_type_data['type']]
1570 : ucfirst($schema_type_data['type']);
1571
1572 foreach ($validation_errors as $error) {
1573 $all_validation_errors[] = [
1574 'schema_type' => $schema_type_name,
1575 'field' => $error['field'],
1576 'message' => $error['message']
1577 ];
1578 }
1579 }
1580 }
1581
1582 // If there are validation errors, return them instead of generating preview
1583 if (!empty($all_validation_errors)) {
1584 wp_send_json_error([
1585 'message' => 'Validation errors detected',
1586 'validation_errors' => $all_validation_errors
1587 ]);
1588 }
1589
1590 // Generate JSON-LD for all schema types
1591 $all_json_ld = [];
1592 foreach ($schema_data['types'] as $schema_type_data) {
1593 $json_ld = $this->generate_json_ld($post, $schema_type_data);
1594 if ($json_ld) {
1595 $all_json_ld[] = $json_ld;
1596 }
1597 }
1598
1599 if (empty($all_json_ld)) {
1600 wp_send_json_error(['message' => 'Could not generate schema markup']);
1601 }
1602
1603 // Build the final JSON-LD structure with @context and @graph
1604 $final_json_ld = [
1605 '@context' => 'https://schema.org',
1606 '@graph' => $all_json_ld
1607 ];
1608
1609 $formatted_json = wp_json_encode($final_json_ld, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
1610
1611 // Wrap in script tags for preview
1612 $formatted_json_with_tags = '<script type="application/ld+json" class="metasync-schema">' . "\n" . $formatted_json . "\n" . '</script>';
1613
1614 wp_send_json_success([
1615 'json' => $formatted_json_with_tags,
1616 'types' => array_column($schema_data['types'], 'type')
1617 ]);
1618 }
1619
1620 /**
1621 * Get the plugin name
1622 *
1623 * @return string The plugin name
1624 */
1625 public function get_plugin_name()
1626 {
1627 return $this->plugin_name;
1628 }
1629
1630 /**
1631 * Get the plugin version
1632 *
1633 * @return string The plugin version
1634 */
1635 public function get_version()
1636 {
1637 return $this->version;
1638 }
1639
1640 /**
1641 * Get images for schema markup
1642 * Currently returns featured image, but designed to support multiple sources in future
1643 *
1644 * @param int $post_id The post ID
1645 * @return array Array of image URLs
1646 */
1647 private function get_post_images($post_id)
1648 {
1649 $images = [];
1650
1651 // 1. Featured Image (Post Thumbnail)
1652 $featured_image_id = get_post_thumbnail_id($post_id);
1653 if ($featured_image_id) {
1654 $featured_image_url = wp_get_attachment_image_url($featured_image_id, 'full');
1655 if ($featured_image_url) {
1656 $images[] = $featured_image_url;
1657 }
1658 }
1659
1660 // Future expansion points:
1661 // 2. Gallery images from post content
1662 // 3. Images from post attachments
1663 // 4. WooCommerce product gallery (if WooCommerce is active)
1664 // 5. Custom field images
1665 // 6. Images from post content (img tags)
1666
1667 return $images;
1668 }
1669
1670 }
1671