PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.0.2
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-schema-builder.php

class-schema-builder.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.0.2, at includes/seo/class-schema-builder.php

1,769 lines 66.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Schema Builder Class
4 *
5 * Handles schema markup construction and JSON-LD output formatting.
6 * Extracted from Schema_Generator to follow Single Responsibility Principle.
7 * Maintains exact same building logic and output formats as original implementation.
8 *
9 * @package ThinkRank\SEO
10 * @since 1.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\SEO;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * Schema Builder Class
24 *
25 * Constructs schema markup from data and formats JSON-LD output.
26 * Preserves all existing building logic and output formats.
27 *
28 * @since 1.0.0
29 */
30 class Schema_Builder {
31
32 /**
33 * Schema Factory instance for base structures
34 *
35 * @since 1.0.0
36 * @var Schema_Factory
37 */
38 private Schema_Factory $schema_factory;
39
40 /**
41 * Constructor
42 *
43 * @since 1.0.0
44 */
45 public function __construct() {
46 // Ensure Schema_Factory is loaded
47 if (!class_exists('ThinkRank\\SEO\\Schema_Factory')) {
48 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-factory.php';
49 }
50 $this->schema_factory = new Schema_Factory();
51 }
52
53 /**
54 * Build schema markup for specific type
55 * PRESERVED: Maintains exact same building logic from original Schema_Generator
56 *
57 * @since 1.0.0
58 *
59 * @param string $schema_type Schema type to build
60 * @param array $data Content data for schema building
61 * @param string $context Context type ('site', 'post', 'page', etc.)
62 * @return array Built schema markup
63 */
64 public function build_schema(string $schema_type, array $data, string $context): array {
65 // Normalize and validate schema type
66 $schema_type = $this->schema_factory->normalize_schema_type($schema_type);
67
68 if (!$this->schema_factory->is_supported_schema_type($schema_type)) {
69 return $this->create_error_schema('Unsupported schema type: ' . $schema_type);
70 }
71
72 // Get base schema structure
73 $schema = $this->schema_factory->create_base_schema($schema_type);
74
75 // Populate schema with data based on type
76 switch ($schema_type) {
77 case 'Article':
78 case 'BlogPosting':
79 case 'TechnicalArticle':
80 case 'NewsArticle':
81 case 'ScholarlyArticle':
82 case 'Report':
83 $schema = $this->populate_article_schema($schema, $data, $context);
84 break;
85 case 'Product':
86 $schema = $this->populate_product_schema($schema, $data, $context);
87 break;
88 case 'Organization':
89 $schema = $this->populate_organization_schema($schema, $data, $context);
90 break;
91 case 'WebSite':
92 $schema = $this->populate_website_schema($schema, $data, $context);
93 break;
94 case 'WebPage':
95 $schema = $this->populate_webpage_schema($schema, $data, $context);
96 break;
97 case 'FAQPage':
98 $schema = $this->populate_faq_schema($schema, $data, $context);
99 break;
100 case 'LocalBusiness':
101 $schema = $this->populate_local_business_schema($schema, $data, $context);
102 break;
103 case 'Person':
104 $schema = $this->populate_person_schema($schema, $data, $context);
105 break;
106 case 'SoftwareApplication':
107 $schema = $this->populate_software_application_schema($schema, $data, $context);
108 break;
109 case 'Event':
110 $schema = $this->populate_event_schema($schema, $data, $context);
111 break;
112 case 'HowTo':
113 $schema = $this->populate_howto_schema($schema, $data, $context);
114 break;
115 default:
116 $schema = $this->populate_generic_schema($schema, $data, $context);
117 break;
118 }
119
120 return $schema;
121 }
122
123 /**
124 * Get JSON-LD formatted output
125 * PRESERVED: Exact same method logic from original Schema_Generator
126 *
127 * @since 1.0.0
128 *
129 * @param array $schema Schema markup array
130 * @return string JSON-LD formatted string
131 */
132 public function get_json_ld_output(array $schema): string {
133 // Ensure proper JSON-LD structure
134 if (!isset($schema['@context'])) {
135 $schema['@context'] = $this->schema_factory->get_schema_context();
136 }
137
138 // Clean up empty values
139 $schema = $this->clean_schema_array($schema);
140
141 // Generate JSON-LD with proper formatting
142 $json_flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
143 if (defined('WP_DEBUG') && WP_DEBUG) {
144 $json_flags |= JSON_PRETTY_PRINT;
145 }
146
147 return wp_json_encode($schema, $json_flags);
148 }
149
150 /**
151 * Create error schema for unsupported types or errors
152 *
153 * @since 1.0.0
154 *
155 * @param string $error_message Error message
156 * @return array Error schema structure
157 */
158 private function create_error_schema(string $error_message): array {
159 return [
160 '@context' => $this->schema_factory->get_schema_context(),
161 '@type' => 'Thing',
162 'name' => 'Schema Generation Error',
163 'description' => $error_message,
164 '_error' => true
165 ];
166 }
167
168 /**
169 * Populate Article schema
170 * PRESERVED: Exact same method logic from original Schema_Generator
171 *
172 * @since 1.0.0
173 *
174 * @param array $schema Base schema
175 * @param array $data Content data
176 * @param string $context Context type
177 * @return array Populated schema
178 */
179 private function populate_article_schema(array $schema, array $data, string $context): array {
180 // Required properties - prioritize user-configured fields
181 $schema['headline'] = $this->truncate_text(
182 $data['site_data']['article_headline'] ?? $data['title'] ?? '',
183 110
184 );
185
186 // Author from user configuration or fallback
187 if (!empty($data['site_data']['article_author'])) {
188 $schema['author'] = [
189 '@type' => 'Person',
190 'name' => $data['site_data']['article_author']
191 ];
192 } else {
193 $schema['author'] = $this->format_author_schema($data['author'] ?? []);
194 }
195
196 // Date published - prioritize user-configured date
197 if (!empty($data['site_data']['article_date_published'])) {
198 $schema['datePublished'] = $data['site_data']['article_date_published'];
199 } elseif (!empty($data['date'])) {
200 $schema['datePublished'] = $data['date'];
201 } else {
202 $schema['datePublished'] = current_time('c');
203 }
204
205 // Recommended properties - prioritize user-configured fields
206 if (!empty($data['site_data']['article_description'])) {
207 $schema['description'] = $this->truncate_text($data['site_data']['article_description'], 160);
208 } elseif (!empty($data['excerpt'])) {
209 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
210 } elseif (!empty($data['content'])) {
211 $schema['description'] = $this->truncate_text($data['content'], 160);
212 }
213
214 // Image from user configuration or fallback
215 if (!empty($data['site_data']['article_image'])) {
216 $schema['image'] = $this->format_image_schema($data['site_data']['article_image']);
217 } elseif (!empty($data['image'])) {
218 $schema['image'] = $this->format_image_schema($data['image']);
219 }
220
221 // Date modified - prioritize user-configured date
222 if (!empty($data['site_data']['article_date_modified'])) {
223 $schema['dateModified'] = $data['site_data']['article_date_modified'];
224 } elseif (!empty($data['modified'])) {
225 $schema['dateModified'] = $data['modified'];
226 } else {
227 $schema['dateModified'] = $schema['datePublished'];
228 }
229
230 $schema['publisher'] = $this->get_organization_schema();
231
232 // URL for the article
233 if (!empty($data['url'])) {
234 $schema['url'] = $data['url'];
235 $schema['mainEntityOfPage'] = [
236 '@type' => 'WebPage',
237 '@id' => $data['url']
238 ];
239 }
240
241 // Optional properties
242 if (!empty($data['content'])) {
243 // Use frontend-calculated word count if provided (from SEO Analysis method)
244 // Otherwise fallback to backend calculation
245 $schema['wordCount'] = isset($data['word_count']) ? (int) $data['word_count'] : str_word_count(wp_strip_all_tags($data['content']));
246
247 // Use focus keyword if available, otherwise extract from content
248 if (!empty($data['focus_keyword'])) {
249 // Split focus keyword into individual keywords
250 $focus_keywords = array_map('trim', explode(',', $data['focus_keyword']));
251 $schema['keywords'] = array_merge($focus_keywords, $this->extract_keywords_from_content($data['content']));
252 // Remove duplicates and limit to 10
253 $schema['keywords'] = array_slice(array_unique($schema['keywords']), 0, 10);
254 } else {
255 $schema['keywords'] = $this->extract_keywords_from_content($data['content']);
256 }
257 }
258
259 return $schema;
260 }
261
262 /**
263 * Populate Product schema
264 * PRESERVED: Exact same method logic from original Schema_Generator
265 *
266 * @since 1.0.0
267 *
268 * @param array $schema Base schema
269 * @param array $data Content data
270 * @param string $context Context type
271 * @return array Populated schema
272 */
273 private function populate_product_schema(array $schema, array $data, string $context): array {
274 // Required properties - prioritize user-configured fields
275 $schema['name'] = $data['site_data']['product_name'] ?? $data['title'] ?? '';
276 $schema['description'] = $data['site_data']['product_description'] ?? $data['excerpt'] ?? $data['content'] ?? '';
277
278 // Truncate description
279 if (!empty($schema['description'])) {
280 $schema['description'] = $this->truncate_text($schema['description'], 160);
281 }
282
283 // Image from user configuration or fallback
284 if (!empty($data['site_data']['product_image'])) {
285 $schema['image'] = $this->format_image_schema($data['site_data']['product_image']);
286 } elseif (!empty($data['image'])) {
287 $schema['image'] = $this->format_image_schema($data['image']);
288 }
289
290 // Brand from user configuration
291 if (!empty($data['site_data']['product_brand'])) {
292 $schema['brand'] = [
293 '@type' => 'Brand',
294 'name' => $data['site_data']['product_brand']
295 ];
296 }
297
298 // SKU from user configuration
299 if (!empty($data['site_data']['product_sku'])) {
300 $schema['sku'] = $data['site_data']['product_sku'];
301 }
302
303 // GTIN from user configuration
304 if (!empty($data['site_data']['product_gtin'])) {
305 $schema['gtin'] = $data['site_data']['product_gtin'];
306 }
307
308 // URL from user configuration or fallback
309 if (!empty($data['site_data']['product_url'])) {
310 $schema['url'] = $data['site_data']['product_url'];
311 } elseif (!empty($data['url'])) {
312 $schema['url'] = $data['url'];
313 }
314
315 // Review from user configuration - check both field name formats
316 if (!empty($data['site_data']['product_review_rating']) && !empty($data['site_data']['product_review_author'])) {
317 $schema['review'] = [
318 '@type' => 'Review',
319 'reviewRating' => [
320 '@type' => 'Rating',
321 'ratingValue' => $data['site_data']['product_review_rating'],
322 'bestRating' => '5'
323 ],
324 'author' => [
325 '@type' => 'Person',
326 'name' => $data['site_data']['product_review_author']
327 ]
328 ];
329 } elseif (!empty($data['site_data']['product_review']) && !empty($data['site_data']['product_rating_value'])) {
330 // Use form fields: product_review text and rating_value
331 $schema['review'] = [
332 '@type' => 'Review',
333 'reviewBody' => $data['site_data']['product_review'],
334 'reviewRating' => [
335 '@type' => 'Rating',
336 'ratingValue' => $data['site_data']['product_rating_value'],
337 'bestRating' => '5'
338 ],
339 'author' => [
340 '@type' => 'Person',
341 'name' => $data['site_data']['organization_name'] ?? get_bloginfo('name')
342 ]
343 ];
344 }
345
346 // Aggregate Rating from user configuration - check both field name formats
347 if (!empty($data['site_data']['product_aggregate_rating']) && !empty($data['site_data']['product_review_count'])) {
348 $schema['aggregateRating'] = [
349 '@type' => 'AggregateRating',
350 'ratingValue' => $data['site_data']['product_aggregate_rating'],
351 'reviewCount' => $data['site_data']['product_review_count'],
352 'bestRating' => '5'
353 ];
354 } elseif (!empty($data['site_data']['product_rating_value']) && !empty($data['site_data']['product_rating_count'])) {
355 // Use form fields: product_rating_value and product_rating_count
356 $schema['aggregateRating'] = [
357 '@type' => 'AggregateRating',
358 'ratingValue' => $data['site_data']['product_rating_value'],
359 'reviewCount' => $data['site_data']['product_rating_count'],
360 'bestRating' => '5'
361 ];
362 }
363
364 // Offers - prioritize user-configured price
365 $offers = [
366 '@type' => 'Offer',
367 'availability' => 'https://schema.org/InStock',
368 'priceCurrency' => $data['site_data']['product_currency'] ?? 'USD'
369 ];
370
371 if (!empty($data['site_data']['product_price'])) {
372 $offers['price'] = $data['site_data']['product_price'];
373 }
374
375 if (!empty($data['url'])) {
376 $offers['url'] = $data['url'];
377 }
378
379 $schema['offers'] = $offers;
380
381 // Fallback to content extraction if user fields are empty
382 if (empty($schema['name']) || empty($schema['description'])) {
383 $product_data = $this->extract_product_data($data['content'] ?? '');
384
385 if (empty($schema['name']) && !empty($product_data['name'])) {
386 $schema['name'] = $product_data['name'];
387 }
388
389 if (empty($schema['sku']) && !empty($product_data['sku'])) {
390 $schema['sku'] = $product_data['sku'];
391 }
392
393 if (empty($schema['brand']) && !empty($product_data['brand'])) {
394 $schema['brand'] = [
395 '@type' => 'Brand',
396 'name' => $product_data['brand']
397 ];
398 }
399
400 if (empty($offers['price']) && !empty($product_data['price'])) {
401 $schema['offers']['price'] = $product_data['price'];
402 }
403 }
404
405 return $schema;
406 }
407
408 /**
409 * Populate Organization schema
410 * PRESERVED: Exact same method logic from original Schema_Generator
411 *
412 * @since 1.0.0
413 *
414 * @param array $schema Base schema
415 * @param array $data Content data
416 * @param string $context Context type
417 * @return array Populated schema
418 */
419 private function populate_organization_schema(array $schema, array $data, string $context): array {
420 // Get business data from Site Identity Business Info (single source of truth)
421 $business_data = $this->get_business_data_from_site_identity();
422
423 // Required properties - prioritize Schema Manager organization settings
424 $schema['name'] = $data['site_data']['organization_name'] ?? $business_data['business_name'] ?? $data['title'] ?? get_bloginfo('name');
425 $schema['url'] = $data['site_data']['organization_url'] ?? $business_data['business_website'] ?? $data['url'] ?? home_url();
426
427 // Logo from user configuration or theme customizer
428 if (!empty($data['site_data']['organization_logo'])) {
429 $schema['logo'] = $this->format_image_schema($data['site_data']['organization_logo']);
430 } else {
431 // Fallback to WordPress custom logo
432 $custom_logo_id = get_theme_mod('custom_logo');
433 if ($custom_logo_id) {
434 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'full');
435 if ($logo_data) {
436 $schema['logo'] = [
437 '@type' => 'ImageObject',
438 'url' => $logo_data[0],
439 'width' => $logo_data[1],
440 'height' => $logo_data[2]
441 ];
442 }
443 }
444 }
445
446 // Contact point from Business Info or contact configuration
447 $contact_point = ['@type' => 'ContactPoint'];
448 $has_contact_info = false;
449
450 // Use Business Info phone as primary contact
451 if (!empty($business_data['business_phone'])) {
452 $contact_point['telephone'] = $business_data['business_phone'];
453 $has_contact_info = true;
454 }
455
456 // Use Business Info email as primary contact
457 if (!empty($business_data['business_email'])) {
458 $contact_point['email'] = $business_data['business_email'];
459 $has_contact_info = true;
460 }
461
462 // Add contact type and hours if available
463 if ($has_contact_info) {
464 $contact_point['contactType'] = 'customer service';
465
466 // Add contact hours if available from organization settings
467 if (!empty($data['site_data']['organization_contact_hours'])) {
468 $contact_point['hoursAvailable'] = $data['site_data']['organization_contact_hours'];
469 }
470
471 $schema['contactPoint'] = $contact_point;
472 }
473
474 // Address from Business Info (single source of truth)
475 if (!empty($business_data['business_address'])) {
476 $schema['address'] = [
477 '@type' => 'PostalAddress',
478 'streetAddress' => $business_data['business_address']
479 ];
480
481 // Add additional address components if available
482 if (!empty($business_data['business_city'])) {
483 $schema['address']['addressLocality'] = $business_data['business_city'];
484 }
485 if (!empty($business_data['business_state'])) {
486 $schema['address']['addressRegion'] = $business_data['business_state'];
487 }
488 if (!empty($business_data['business_postal_code'])) {
489 $schema['address']['postalCode'] = $business_data['business_postal_code'];
490 }
491 if (!empty($business_data['business_country'])) {
492 $schema['address']['addressCountry'] = $business_data['business_country'];
493 }
494 }
495
496 // Social media profiles
497 $social_profiles = $this->get_social_profiles();
498 if (!empty($social_profiles)) {
499 $schema['sameAs'] = $social_profiles;
500 }
501
502 // Add description - prioritize Schema Manager organization description
503 if (!empty($data['site_data']['organization_description'])) {
504 $schema['description'] = $this->truncate_text($data['site_data']['organization_description'], 160);
505 } elseif (!empty($data['content'])) {
506 $schema['description'] = $this->truncate_text($data['content'], 160);
507 }
508
509 return $schema;
510 }
511
512 /**
513 * Truncate text to specified length
514 * PRESERVED: Exact same method logic from original Schema_Generator
515 *
516 * @since 1.0.0
517 *
518 * @param string $text Text to truncate
519 * @param int $length Maximum length
520 * @return string Truncated text
521 */
522 private function truncate_text(string $text, int $length): string {
523 $text = wp_strip_all_tags($text);
524 if (strlen($text) <= $length) {
525 return $text;
526 }
527 return substr($text, 0, $length - 3) . '...';
528 }
529
530 /**
531 * Format author schema
532 * PRESERVED: Exact same method logic from original Schema_Generator
533 *
534 * @since 1.0.0
535 *
536 * @param array $author_data Author data
537 * @return array Formatted author schema
538 */
539 private function format_author_schema(array $author_data): array {
540 if (empty($author_data['name'])) {
541 return [
542 '@type' => 'Person',
543 'name' => get_bloginfo('name')
544 ];
545 }
546
547 $author_schema = [
548 '@type' => 'Person',
549 'name' => $author_data['name']
550 ];
551
552 if (!empty($author_data['url'])) {
553 $author_schema['url'] = $author_data['url'];
554 }
555
556 if (!empty($author_data['description'])) {
557 $author_schema['description'] = $this->truncate_text($author_data['description'], 160);
558 }
559
560 return $author_schema;
561 }
562
563 /**
564 * Format image schema
565 * PRESERVED: Exact same method logic from original Schema_Generator
566 *
567 * @since 1.0.0
568 *
569 * @param string $image_url Image URL
570 * @return array Formatted image schema
571 */
572 private function format_image_schema(string $image_url): array {
573 $image_schema = [
574 '@type' => 'ImageObject',
575 'url' => $image_url
576 ];
577
578 // Try to get image dimensions if it's a WordPress attachment
579 $attachment_id = attachment_url_to_postid($image_url);
580 if ($attachment_id) {
581 $image_data = wp_get_attachment_image_src($attachment_id, 'full');
582 if ($image_data) {
583 $image_schema['width'] = $image_data[1];
584 $image_schema['height'] = $image_data[2];
585 }
586 }
587
588 return $image_schema;
589 }
590
591 /**
592 * Get organization schema for publisher
593 * PRESERVED: Exact same method logic from original Schema_Generator
594 *
595 * @since 1.0.0
596 *
597 * @return array Organization schema
598 */
599 private function get_organization_schema(): array {
600 $org_schema = [
601 '@type' => 'Organization',
602 'name' => get_bloginfo('name'),
603 'url' => home_url()
604 ];
605
606 // Add logo if available
607 $custom_logo_id = get_theme_mod('custom_logo');
608 if ($custom_logo_id) {
609 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'full');
610 if ($logo_data) {
611 $org_schema['logo'] = [
612 '@type' => 'ImageObject',
613 'url' => $logo_data[0],
614 'width' => $logo_data[1],
615 'height' => $logo_data[2]
616 ];
617 }
618 }
619
620 return $org_schema;
621 }
622
623 /**
624 * Get social media profiles
625 * Enhanced to retrieve from Schema Manager organization settings
626 *
627 * @since 1.0.0
628 *
629 * @return array Social media profile URLs
630 */
631 private function get_social_profiles(): array {
632 // Get Schema Manager settings for organization social profiles
633 $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
634 $settings = $schema_manager->get_settings('site', null);
635
636 $social_profiles = [];
637
638 // Organization social media fields from Schema Manager
639 $social_fields = [
640 'organization_social_facebook',
641 'organization_social_twitter',
642 'organization_social_linkedin',
643 'organization_social_instagram',
644 'organization_social_youtube'
645 ];
646
647 foreach ($social_fields as $field) {
648 if (!empty($settings[$field]) && filter_var($settings[$field], FILTER_VALIDATE_URL)) {
649 $social_profiles[] = $settings[$field];
650 }
651 }
652
653 return $social_profiles;
654 }
655
656 /**
657 * Extract keywords from content
658 * PRESERVED: Exact same method logic from original Schema_Generator
659 *
660 * @since 1.0.0
661 *
662 * @param string $content Content to analyze
663 * @return array Extracted keywords
664 */
665 private function extract_keywords_from_content(string $content): array {
666 // Simple keyword extraction - can be enhanced with AI
667 $content = wp_strip_all_tags($content);
668 $words = str_word_count($content, 1);
669
670 // Filter out common words and short words
671 $common_words = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'this', 'that', 'these', 'those', 'a', 'an'];
672
673 $keywords = [];
674 foreach ($words as $word) {
675 $word = strtolower(trim($word));
676 if (strlen($word) > 3 && !in_array($word, $common_words)) {
677 $keywords[] = $word;
678 }
679 }
680
681 // Return top 10 most frequent keywords
682 $keyword_counts = array_count_values($keywords);
683 arsort($keyword_counts);
684 return array_slice(array_keys($keyword_counts), 0, 10);
685 }
686
687 /**
688 * Extract product data from content
689 * PRESERVED: Exact same method logic from original Schema_Generator
690 *
691 * @since 1.0.0
692 *
693 * @param string $content Content to analyze
694 * @return array Extracted product data
695 */
696 private function extract_product_data(string $content): array {
697 $product_data = [];
698
699 // Extract price
700 if (preg_match('/\$([0-9,]+\.?[0-9]*)/i', $content, $matches)) {
701 $product_data['price'] = str_replace(',', '', $matches[1]);
702 }
703
704 // Extract SKU
705 if (preg_match('/sku[:\s]+([a-z0-9\-]+)/i', $content, $matches)) {
706 $product_data['sku'] = $matches[1];
707 }
708
709 // Extract brand (simple pattern)
710 if (preg_match('/brand[:\s]+([a-z\s]+)/i', $content, $matches)) {
711 $product_data['brand'] = trim($matches[1]);
712 }
713
714 return $product_data;
715 }
716
717 /**
718 * Clean schema array by removing empty values
719 * PRESERVED: Exact same method logic from original Schema_Generator
720 *
721 * @since 1.0.0
722 *
723 * @param array $schema Schema array to clean
724 * @return array Cleaned schema array
725 */
726 private function clean_schema_array(array $schema): array {
727 // Remove internal validation metadata (should not be in final output)
728 if (isset($schema['_validation'])) {
729 unset($schema['_validation']);
730 }
731 if (isset($schema['_error'])) {
732 unset($schema['_error']);
733 }
734
735 // Remove empty values recursively, but preserve critical schema fields
736 $critical_fields = ['@context', '@type', '@id'];
737
738 foreach ($schema as $key => $value) {
739 if (is_array($value)) {
740 $schema[$key] = $this->clean_schema_array($value);
741 if (empty($schema[$key])) {
742 unset($schema[$key]);
743 }
744 } elseif (empty($value) && $value !== 0 && $value !== '0' && !in_array($key, $critical_fields)) {
745 unset($schema[$key]);
746 }
747 }
748
749 return $schema;
750 }
751
752 /**
753 * Populate Website schema
754 * PRESERVED: Exact same method logic from original Schema_Generator
755 *
756 * @since 1.0.0
757 *
758 * @param array $schema Base schema
759 * @param array $data Content data
760 * @param string $context Context type
761 * @return array Populated schema
762 */
763 private function populate_website_schema(array $schema, array $data, string $context): array {
764 // Required properties - prioritize user-configured Website schema fields
765 $schema['name'] = $data['site_data']['website_name'] ?? $data['title'] ?? get_bloginfo('name');
766 $schema['url'] = $data['site_data']['website_url'] ?? $data['url'] ?? home_url();
767
768 // Recommended properties - prioritize user-configured Website schema description
769 if (!empty($data['site_data']['website_description'])) {
770 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
771 } elseif (!empty($data['content'])) {
772 $schema['description'] = $this->truncate_text($data['content'], 160);
773 } else {
774 $schema['description'] = get_bloginfo('description');
775 }
776
777 // Author - use organization or person data
778 if (!empty($data['site_data']['organization_name'])) {
779 $schema['author'] = [
780 '@type' => 'Organization',
781 'name' => $data['site_data']['organization_name']
782 ];
783 } elseif (!empty($data['site_data']['person_name'])) {
784 $schema['author'] = [
785 '@type' => 'Person',
786 'name' => $data['site_data']['person_name']
787 ];
788 } else {
789 // Fallback to site name as organization
790 $schema['author'] = [
791 '@type' => 'Organization',
792 'name' => get_bloginfo('name')
793 ];
794 }
795
796 // Publisher - enhanced with logo from Site Identity
797 $publisher = [
798 '@type' => 'Organization',
799 'name' => $data['site_data']['organization_name'] ?? get_bloginfo('name'),
800 'url' => $data['site_data']['organization_url'] ?? home_url()
801 ];
802
803 // Add logo to publisher from Site Identity or user configuration
804 if (!empty($data['site_data']['logo_url'])) {
805 $publisher['logo'] = $this->format_image_schema($data['site_data']['logo_url']);
806 } elseif (!empty($data['site_data']['organization_logo'])) {
807 $publisher['logo'] = $this->format_image_schema($data['site_data']['organization_logo']);
808 } else {
809 // Fallback to WordPress custom logo
810 $custom_logo_id = get_theme_mod('custom_logo');
811 if ($custom_logo_id) {
812 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'full');
813 if ($logo_data) {
814 $publisher['logo'] = [
815 '@type' => 'ImageObject',
816 'url' => $logo_data[0],
817 'width' => $logo_data[1],
818 'height' => $logo_data[2]
819 ];
820 }
821 }
822 }
823
824 $schema['publisher'] = $publisher;
825
826 // Search action for sitelinks search box (optional but recommended)
827 if ($data['site_data']['website_enable_search'] ?? true) {
828 $search_url = $data['site_data']['website_search_url'] ?? home_url('/?s={search_term_string}');
829 $schema['potentialAction'] = [
830 '@type' => 'SearchAction',
831 'target' => [
832 '@type' => 'EntryPoint',
833 'urlTemplate' => $search_url
834 ],
835 'query-input' => 'required name=search_term_string'
836 ];
837 }
838
839 // Social media profiles (sameAs)
840 $social_profiles = $this->get_social_profiles();
841 if (!empty($social_profiles)) {
842 $schema['sameAs'] = $social_profiles;
843 }
844
845 // Language
846 $schema['inLanguage'] = get_locale();
847
848 return $schema;
849 }
850
851 /**
852 * Populate WebPage schema
853 * PRESERVED: Exact same method logic from original Schema_Generator
854 *
855 * @since 1.0.0
856 *
857 * @param array $schema Base schema
858 * @param array $data Content data
859 * @param string $context Context type
860 * @return array Populated schema
861 */
862 private function populate_webpage_schema(array $schema, array $data, string $context): array {
863 // Required properties
864 $schema['name'] = $data['title'] ?? '';
865 $schema['url'] = $data['url'] ?? '';
866
867 // Optional properties
868 if (!empty($data['excerpt'])) {
869 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
870 } elseif (!empty($data['content'])) {
871 $schema['description'] = $this->truncate_text($data['content'], 160);
872 }
873
874 if (!empty($data['date'])) {
875 $schema['datePublished'] = $data['date'];
876 }
877
878 if (!empty($data['modified'])) {
879 $schema['dateModified'] = $data['modified'];
880 }
881
882 $schema['isPartOf'] = [
883 '@type' => 'WebSite',
884 'name' => get_bloginfo('name'),
885 'url' => home_url()
886 ];
887
888 return $schema;
889 }
890
891 /**
892 * Populate FAQ schema
893 * PRESERVED: Exact same method logic from original Schema_Generator
894 *
895 * @since 1.0.0
896 *
897 * @param array $schema Base schema
898 * @param array $data Content data
899 * @param string $context Context type
900 * @return array Populated schema
901 */
902 private function populate_faq_schema(array $schema, array $data, string $context): array {
903 // Use user-configured FAQ questions first, then fallback to content extraction
904 $faq_data = [];
905
906 // Check for user-configured FAQ questions
907 if (!empty($data['site_data']['faq_questions']) && is_array($data['site_data']['faq_questions'])) {
908 foreach ($data['site_data']['faq_questions'] as $faq_item) {
909 if (!empty($faq_item['question']) && !empty($faq_item['answer'])) {
910 $faq_data[] = [
911 '@type' => 'Question',
912 'name' => $faq_item['question'],
913 'acceptedAnswer' => [
914 '@type' => 'Answer',
915 'text' => $faq_item['answer']
916 ]
917 ];
918 }
919 }
920 }
921
922 // Fallback to content extraction if no user-configured questions
923 if (empty($faq_data) && !empty($data['content'])) {
924 $extracted_faq = $this->extract_faq_data($data['content']);
925 foreach ($extracted_faq as $faq_item) {
926 $faq_data[] = [
927 '@type' => 'Question',
928 'name' => $faq_item['question'],
929 'acceptedAnswer' => [
930 '@type' => 'Answer',
931 'text' => $faq_item['answer']
932 ]
933 ];
934 }
935 }
936
937 $schema['mainEntity'] = $faq_data;
938
939 // Optional properties
940 $schema['name'] = $data['title'] ?? 'Frequently Asked Questions';
941 if (!empty($data['excerpt'])) {
942 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
943 }
944
945 // URL for the FAQ page
946 if (!empty($data['url'])) {
947 $schema['url'] = $data['url'];
948 }
949
950 // About - recommended property
951 if (!empty($data['site_data']['faq_page_description'])) {
952 $schema['about'] = $data['site_data']['faq_page_description'];
953 } elseif (!empty($data['excerpt'])) {
954 $schema['about'] = $this->truncate_text($data['excerpt'], 160);
955 }
956
957 // Author - recommended property
958 if (!empty($data['site_data']['organization_name'])) {
959 $schema['author'] = [
960 '@type' => 'Organization',
961 'name' => $data['site_data']['organization_name']
962 ];
963 } elseif (!empty($data['site_data']['person_name'])) {
964 $schema['author'] = [
965 '@type' => 'Person',
966 'name' => $data['site_data']['person_name']
967 ];
968 } elseif (!empty($data['author']['name'])) {
969 $schema['author'] = [
970 '@type' => 'Person',
971 'name' => $data['author']['name']
972 ];
973 }
974
975 return $schema;
976 }
977
978 /**
979 * Populate LocalBusiness schema
980 * PRESERVED: Exact same method logic from original Schema_Generator
981 *
982 * @since 1.0.0
983 *
984 * @param array $schema Base schema
985 * @param array $data Content data
986 * @param string $context Context type
987 * @return array Populated schema
988 */
989 private function populate_local_business_schema(array $schema, array $data, string $context): array {
990 // Get business data from Site Identity Business Info (single source of truth)
991 $business_data = $this->get_business_data_from_site_identity();
992
993 // Required properties - use business name from Business Info
994 $schema['name'] = $business_data['business_name'] ?? $data['site_data']['organization_name'] ?? $data['title'] ?? get_bloginfo('name');
995
996 // Address is required for LocalBusiness - use Business Info data
997 if (!empty($business_data['business_address'])) {
998 $schema['address'] = [
999 '@type' => 'PostalAddress',
1000 'streetAddress' => $business_data['business_address']
1001 ];
1002
1003 // Add additional address components if available
1004 if (!empty($business_data['business_city'])) {
1005 $schema['address']['addressLocality'] = $business_data['business_city'];
1006 }
1007 if (!empty($business_data['business_state'])) {
1008 $schema['address']['addressRegion'] = $business_data['business_state'];
1009 }
1010 if (!empty($business_data['business_postal_code'])) {
1011 $schema['address']['postalCode'] = $business_data['business_postal_code'];
1012 }
1013 if (!empty($business_data['business_country'])) {
1014 $schema['address']['addressCountry'] = $business_data['business_country'];
1015 }
1016 } else {
1017 // Fallback to content extraction only if no Business Info data
1018 $extracted_data = $this->extract_business_data($data['content'] ?? '');
1019 if (!empty($extracted_data['address'])) {
1020 $schema['address'] = [
1021 '@type' => 'PostalAddress',
1022 'streetAddress' => $extracted_data['address']
1023 ];
1024 }
1025 }
1026
1027 // Telephone - use Business Info phone
1028 if (!empty($business_data['business_phone'])) {
1029 $schema['telephone'] = $business_data['business_phone'];
1030 } else {
1031 // Fallback to content extraction
1032 $extracted_data = $this->extract_business_data($data['content'] ?? '');
1033 if (!empty($extracted_data['phone'])) {
1034 $schema['telephone'] = $extracted_data['phone'];
1035 }
1036 }
1037
1038 // Opening hours from Business Info
1039 if (!empty($business_data['business_hours']) && is_array($business_data['business_hours'])) {
1040 $schema['openingHours'] = $this->format_business_hours($business_data['business_hours']);
1041 } else {
1042 // Fallback to content extraction
1043 $extracted_data = $this->extract_business_data($data['content'] ?? '');
1044 if (!empty($extracted_data['hours'])) {
1045 $schema['openingHours'] = $extracted_data['hours'];
1046 }
1047 }
1048
1049 // Geo coordinates from Business Info
1050 if (!empty($business_data['business_latitude']) && !empty($business_data['business_longitude'])) {
1051 $schema['geo'] = [
1052 '@type' => 'GeoCoordinates',
1053 'latitude' => $business_data['business_latitude'],
1054 'longitude' => $business_data['business_longitude']
1055 ];
1056 }
1057
1058 // Add URL - use business website or site URL
1059 $schema['url'] = $business_data['business_website'] ?? home_url();
1060
1061 // Add description - use business description or site description
1062 if (!empty($business_data['business_description'])) {
1063 $schema['description'] = $this->truncate_text($business_data['business_description'], 160);
1064 } elseif (!empty($data['content'])) {
1065 $schema['description'] = $this->truncate_text($data['content'], 160);
1066 }
1067
1068 // Price range from Business Info
1069 if (!empty($business_data['business_price_range'])) {
1070 $schema['priceRange'] = $business_data['business_price_range'];
1071 }
1072
1073 // Price range - recommended property
1074 if (!empty($data['site_data']['business_price_range'])) {
1075 $schema['priceRange'] = $data['site_data']['business_price_range'];
1076 }
1077
1078 // Logo from Site Identity assets or WordPress custom logo
1079 if (!empty($data['site_data']['logo_url'])) {
1080 $schema['logo'] = $this->format_image_schema($data['site_data']['logo_url']);
1081 } else {
1082 // Fallback to WordPress custom logo
1083 $custom_logo_id = get_theme_mod('custom_logo');
1084 if ($custom_logo_id) {
1085 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'full');
1086 if ($logo_data) {
1087 $schema['logo'] = [
1088 '@type' => 'ImageObject',
1089 'url' => $logo_data[0],
1090 'width' => $logo_data[1],
1091 'height' => $logo_data[2]
1092 ];
1093 }
1094 }
1095 }
1096
1097 // Social media profiles from Organization settings (sameAs property)
1098 $social_profiles = [];
1099 $social_fields = [
1100 'organization_social_facebook',
1101 'organization_social_twitter',
1102 'organization_social_linkedin',
1103 'organization_social_instagram',
1104 'organization_social_youtube'
1105 ];
1106
1107 foreach ($social_fields as $field) {
1108 if (!empty($data['site_data'][$field])) {
1109 $social_profiles[] = $data['site_data'][$field];
1110 }
1111 }
1112
1113 if (!empty($social_profiles)) {
1114 $schema['sameAs'] = $social_profiles;
1115 }
1116
1117 return $schema;
1118 }
1119
1120 /**
1121 * Get business data from Site Identity Business Info (single source of truth)
1122 *
1123 * @since 1.0.0
1124 *
1125 * @return array Business data array
1126 */
1127 private function get_business_data_from_site_identity(): array {
1128 // Get Site Identity Manager
1129 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
1130 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
1131 }
1132
1133 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
1134 $settings = $site_identity_manager->get_settings('site');
1135
1136 // Only return data if local SEO is enabled
1137 if (empty($settings['local_seo_enabled'])) {
1138 return [];
1139 }
1140
1141 return [
1142 'business_name' => $settings['business_name'] ?? '',
1143 'business_address' => $settings['business_address'] ?? '',
1144 'business_city' => $settings['business_city'] ?? '',
1145 'business_state' => $settings['business_state'] ?? '',
1146 'business_postal_code' => $settings['business_postal_code'] ?? '',
1147 'business_country' => $settings['business_country'] ?? '',
1148 'business_phone' => $settings['business_phone'] ?? '',
1149 'business_email' => $settings['business_email'] ?? '',
1150 'business_hours' => $settings['business_hours'] ?? [],
1151 'business_website' => $settings['business_website'] ?? home_url(),
1152 'business_latitude' => $settings['business_latitude'] ?? '',
1153 'business_longitude' => $settings['business_longitude'] ?? '',
1154 'business_description' => $settings['business_description'] ?? $settings['site_description'] ?? '',
1155 'business_type' => $settings['business_type'] ?? 'LocalBusiness',
1156 'business_price_range' => $settings['business_price_range'] ?? ''
1157 ];
1158 }
1159
1160 /**
1161 * Populate Person schema
1162 * PRESERVED: Exact same method logic from original Schema_Generator
1163 *
1164 * @since 1.0.0
1165 *
1166 * @param array $schema Base schema
1167 * @param array $data Content data
1168 * @param string $context Context type
1169 * @return array Populated schema
1170 */
1171 private function populate_person_schema(array $schema, array $data, string $context): array {
1172 // Required properties - prioritize user-configured fields
1173 $schema['name'] = $data['site_data']['person_name'] ?? $data['author']['name'] ?? $data['title'] ?? '';
1174
1175 // Image from user configuration or fallback
1176 if (!empty($data['site_data']['person_image'])) {
1177 $schema['image'] = $this->format_image_schema($data['site_data']['person_image']);
1178 } elseif (!empty($data['image'])) {
1179 $schema['image'] = $this->format_image_schema($data['image']);
1180 }
1181
1182 // URL from user configuration or fallback
1183 if (!empty($data['site_data']['person_url'])) {
1184 $schema['url'] = $data['site_data']['person_url'];
1185 } elseif (!empty($data['url'])) {
1186 $schema['url'] = $data['url'];
1187 }
1188
1189 // Job title from user configuration
1190 if (!empty($data['site_data']['person_job_title'])) {
1191 $schema['jobTitle'] = $data['site_data']['person_job_title'];
1192 }
1193
1194 // Works for organization - Fixed field name from person_organization to person_works_for
1195 if (!empty($data['site_data']['person_works_for'])) {
1196 $schema['worksFor'] = [
1197 '@type' => 'Organization',
1198 'name' => $data['site_data']['person_works_for']
1199 ];
1200 }
1201
1202 // Description from user configuration or fallback
1203 if (!empty($data['site_data']['person_description'])) {
1204 $schema['description'] = $this->truncate_text($data['site_data']['person_description'], 160);
1205 } elseif (!empty($data['excerpt'])) {
1206 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1207 } elseif (!empty($data['content'])) {
1208 $schema['description'] = $this->truncate_text($data['content'], 160);
1209 }
1210
1211 // Email from user configuration
1212 if (!empty($data['site_data']['person_email'])) {
1213 $schema['email'] = $data['site_data']['person_email'];
1214 }
1215
1216 // Telephone from user configuration
1217 if (!empty($data['site_data']['person_telephone'])) {
1218 $schema['telephone'] = $data['site_data']['person_telephone'];
1219 }
1220
1221 // Nationality from user configuration
1222 if (!empty($data['site_data']['person_nationality'])) {
1223 $schema['nationality'] = $data['site_data']['person_nationality'];
1224 }
1225
1226 // Birth date from user configuration
1227 if (!empty($data['site_data']['person_birth_date'])) {
1228 $schema['birthDate'] = $data['site_data']['person_birth_date'];
1229 }
1230
1231 // Address from user configuration
1232 if (!empty($data['site_data']['person_address'])) {
1233 $schema['address'] = $data['site_data']['person_address'];
1234 }
1235
1236 // Social media profiles - Enhanced to include user-configured sameAs
1237 $social_profiles = [];
1238
1239 // Get user-configured social profiles first
1240 if (!empty($data['site_data']['person_same_as']) && is_array($data['site_data']['person_same_as'])) {
1241 $social_profiles = array_merge($social_profiles, $data['site_data']['person_same_as']);
1242 }
1243
1244 // Add global social profiles as fallback
1245 $global_social_profiles = $this->get_social_profiles();
1246 if (!empty($global_social_profiles)) {
1247 $social_profiles = array_merge($social_profiles, $global_social_profiles);
1248 }
1249
1250 // Remove duplicates and empty values
1251 $social_profiles = array_unique(array_filter($social_profiles));
1252
1253 if (!empty($social_profiles)) {
1254 $schema['sameAs'] = $social_profiles;
1255 }
1256
1257 return $schema;
1258 }
1259
1260 /**
1261 * Populate generic schema for unsupported types
1262 * PRESERVED: Exact same method logic from original Schema_Generator
1263 *
1264 * @since 1.0.0
1265 *
1266 * @param array $schema Base schema
1267 * @param array $data Content data
1268 * @param string $context Context type
1269 * @return array Populated schema
1270 */
1271 private function populate_generic_schema(array $schema, array $data, string $context): array {
1272 // Basic properties that apply to most schema types
1273 if (!empty($data['title'])) {
1274 $schema['name'] = $data['title'];
1275 }
1276 if (!empty($data['excerpt'])) {
1277 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1278 }
1279 if (!empty($data['url'])) {
1280 $schema['url'] = $data['url'];
1281 }
1282
1283 return $schema;
1284 }
1285
1286 /**
1287 * Extract FAQ data from content
1288 * PRESERVED: Exact same method logic from original Schema_Generator
1289 *
1290 * @since 1.0.0
1291 *
1292 * @param string $content Content to analyze
1293 * @return array Extracted FAQ data
1294 */
1295 private function extract_faq_data(string $content): array {
1296 $faq_data = [];
1297
1298 // Look for question/answer patterns
1299 $patterns = [
1300 '/Q:\s*(.+?)\s*A:\s*(.+?)(?=Q:|$)/s',
1301 '/\?\s*(.+?)\n(.+?)(?=\?|$)/s',
1302 '/<h[1-6][^>]*>\s*(.+?)\s*<\/h[1-6]>\s*<p>\s*(.+?)\s*<\/p>/s'
1303 ];
1304
1305 foreach ($patterns as $pattern) {
1306 if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
1307 foreach ($matches as $match) {
1308 if (count($match) >= 3) {
1309 $question = trim(wp_strip_all_tags($match[1]));
1310 $answer = trim(wp_strip_all_tags($match[2]));
1311
1312 if (!empty($question) && !empty($answer)) {
1313 $faq_data[] = [
1314 'question' => $question,
1315 'answer' => $answer
1316 ];
1317 }
1318 }
1319 }
1320 break; // Use first matching pattern
1321 }
1322 }
1323
1324 return $faq_data;
1325 }
1326
1327 /**
1328 * Extract business data from content
1329 * PRESERVED: Exact same method logic from original Schema_Generator
1330 *
1331 * @since 1.0.0
1332 *
1333 * @param string $content Content to analyze
1334 * @return array Extracted business data
1335 */
1336 private function extract_business_data(string $content): array {
1337 $business_data = [];
1338
1339 // Extract phone number
1340 if (preg_match('/(\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4})/', $content, $matches)) {
1341 $business_data['phone'] = $matches[1];
1342 }
1343
1344 // Extract address (simple pattern)
1345 if (preg_match('/([0-9]+\s+[A-Za-z\s]+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct))/i', $content, $matches)) {
1346 $business_data['address'] = $matches[1];
1347 }
1348
1349 // Extract hours (simple pattern)
1350 if (preg_match('/(Monday|Mon).*?([0-9]{1,2}:[0-9]{2}\s*(?:AM|PM|am|pm))/i', $content, $matches)) {
1351 $business_data['hours'] = ['Monday 9:00 AM - 5:00 PM']; // Simplified
1352 }
1353
1354 return $business_data;
1355 }
1356
1357 /**
1358 * Format business hours
1359 * PRESERVED: Exact same method logic from original Schema_Generator
1360 *
1361 * @since 1.0.0
1362 *
1363 * @param array $business_hours Business hours array
1364 * @return array Formatted opening hours
1365 */
1366 private function format_business_hours(array $business_hours): array {
1367 $opening_hours = [];
1368
1369 $day_mapping = [
1370 'monday' => 'Mo',
1371 'tuesday' => 'Tu',
1372 'wednesday' => 'We',
1373 'thursday' => 'Th',
1374 'friday' => 'Fr',
1375 'saturday' => 'Sa',
1376 'sunday' => 'Su'
1377 ];
1378
1379 foreach ($business_hours as $day => $hours) {
1380 $day_code = $day_mapping[strtolower($day)] ?? $day;
1381 if (!empty($hours['open']) && !empty($hours['close'])) {
1382 $opening_hours[] = "{$day_code} {$hours['open']}-{$hours['close']}";
1383 }
1384 }
1385
1386 return $opening_hours;
1387 }
1388
1389 /**
1390 * Populate SoftwareApplication schema
1391 * PRESERVED: Exact same method logic from original Schema_Generator
1392 *
1393 * @since 1.0.0
1394 *
1395 * @param array $schema Base schema
1396 * @param array $data Content data
1397 * @param string $context Context type
1398 * @return array Populated schema
1399 */
1400 private function populate_software_application_schema(array $schema, array $data, string $context): array {
1401 // Required properties - prioritize user-configured fields
1402 $schema['name'] = $data['site_data']['software_name'] ?? $data['title'] ?? get_bloginfo('name');
1403 $schema['applicationCategory'] = $data['site_data']['software_category'] ?? 'WebApplication';
1404
1405 // Recommended properties
1406 if (!empty($data['site_data']['software_description'])) {
1407 $schema['description'] = $this->truncate_text($data['site_data']['software_description'], 160);
1408 } elseif (!empty($data['excerpt'])) {
1409 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1410 } elseif (!empty($data['content'])) {
1411 $schema['description'] = $this->truncate_text($data['content'], 160);
1412 }
1413
1414 // URL from user configuration or fallback
1415 if (!empty($data['site_data']['software_url'])) {
1416 $schema['url'] = $data['site_data']['software_url'];
1417 } elseif (!empty($data['url'])) {
1418 $schema['url'] = $data['url'];
1419 } else {
1420 $schema['url'] = home_url();
1421 }
1422
1423 // Creator/Developer - use form data if available
1424 if (!empty($data['site_data']['software_creator'])) {
1425 // Default to 'Person' if creator_type is not specified (since form defaults to Person)
1426 $creator_type = $data['site_data']['software_creator_type'] ?? 'Person';
1427
1428 $schema['creator'] = [
1429 '@type' => $creator_type,
1430 'name' => $data['site_data']['software_creator']
1431 ];
1432
1433 // Add URL for Organization type
1434 if ($creator_type === 'Organization') {
1435 $schema['creator']['url'] = home_url();
1436 }
1437 } else {
1438 // Fallback to site data
1439 $schema['creator'] = [
1440 '@type' => 'Organization',
1441 'name' => $data['site_data']['organization_name'] ?? get_bloginfo('name'),
1442 'url' => home_url()
1443 ];
1444 }
1445
1446 // Features from user configuration
1447 if (!empty($data['site_data']['software_features']) && is_array($data['site_data']['software_features'])) {
1448 $schema['features'] = $data['site_data']['software_features'];
1449 }
1450
1451 // Aggregate Rating from user configuration - check both field name formats
1452 if (!empty($data['site_data']['software_aggregate_rating']) && !empty($data['site_data']['software_review_count'])) {
1453 $schema['aggregateRating'] = [
1454 '@type' => 'AggregateRating',
1455 'ratingValue' => $data['site_data']['software_aggregate_rating'],
1456 'reviewCount' => $data['site_data']['software_review_count'],
1457 'bestRating' => '5'
1458 ];
1459 } elseif (!empty($data['site_data']['software_rating_value']) && !empty($data['site_data']['software_rating_count'])) {
1460 // Use form fields: software_rating_value and software_rating_count
1461 $schema['aggregateRating'] = [
1462 '@type' => 'AggregateRating',
1463 'ratingValue' => $data['site_data']['software_rating_value'],
1464 'reviewCount' => $data['site_data']['software_rating_count'],
1465 'bestRating' => '5'
1466 ];
1467 }
1468
1469 // Offers/Pricing
1470 if (!empty($data['site_data']['software_price'])) {
1471 // Map availability from form data
1472 $availability_mapping = [
1473 'InStock' => 'https://schema.org/InStock',
1474 'OutOfStock' => 'https://schema.org/OutOfStock',
1475 'PreOrder' => 'https://schema.org/PreOrder',
1476 'ComingSoon' => 'https://schema.org/ComingSoon'
1477 ];
1478
1479 $availability = $data['site_data']['software_availability'] ?? 'InStock';
1480 $schema_availability = $availability_mapping[$availability] ?? 'https://schema.org/InStock';
1481
1482 $schema['offers'] = [
1483 '@type' => 'Offer',
1484 'price' => $data['site_data']['software_price'],
1485 'priceCurrency' => $data['site_data']['software_currency'] ?? 'USD',
1486 'availability' => $schema_availability
1487 ];
1488
1489 // Add price valid until if provided
1490 if (!empty($data['site_data']['software_price_valid_until'])) {
1491 $schema['offers']['priceValidUntil'] = $data['site_data']['software_price_valid_until'];
1492 }
1493 }
1494
1495 // Optional properties
1496 if (!empty($data['site_data']['software_version'])) {
1497 $schema['version'] = $data['site_data']['software_version'];
1498 }
1499
1500 // Operating Systems - check both singular and plural forms
1501 if (!empty($data['site_data']['software_operating_systems'])) {
1502 $schema['operatingSystem'] = $data['site_data']['software_operating_systems'];
1503 } elseif (!empty($data['site_data']['software_operating_system'])) {
1504 $schema['operatingSystem'] = $data['site_data']['software_operating_system'];
1505 }
1506
1507 if (!empty($data['site_data']['software_download_url'])) {
1508 $schema['downloadUrl'] = $data['site_data']['software_download_url'];
1509 }
1510
1511 // Image/Screenshot
1512 if (!empty($data['site_data']['software_screenshot'])) {
1513 $schema['screenshot'] = $this->format_image_schema($data['site_data']['software_screenshot']);
1514 } elseif (!empty($data['image'])) {
1515 $schema['screenshot'] = $this->format_image_schema($data['image']);
1516 }
1517
1518 return $schema;
1519 }
1520
1521 /**
1522 * Populate Event schema
1523 * PRESERVED: Exact same method logic from original Schema_Generator
1524 *
1525 * @since 1.0.0
1526 *
1527 * @param array $schema Base schema
1528 * @param array $data Content data
1529 * @param string $context Context type
1530 * @return array Populated schema
1531 */
1532 private function populate_event_schema(array $schema, array $data, string $context): array {
1533 // Required properties - prioritize user-configured fields
1534 $schema['name'] = $data['site_data']['event_name'] ?? $data['title'] ?? '';
1535
1536 // Start date is required
1537 if (!empty($data['site_data']['event_start_date'])) {
1538 $schema['startDate'] = $data['site_data']['event_start_date'];
1539 } else {
1540 // Fallback to current date if not specified
1541 $schema['startDate'] = current_time('c');
1542 }
1543
1544 // Recommended properties
1545 if (!empty($data['site_data']['event_description'])) {
1546 $schema['description'] = $this->truncate_text($data['site_data']['event_description'], 160);
1547 } elseif (!empty($data['excerpt'])) {
1548 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1549 } elseif (!empty($data['content'])) {
1550 $schema['description'] = $this->truncate_text($data['content'], 160);
1551 }
1552
1553 // Location
1554 if (!empty($data['site_data']['event_location'])) {
1555 $schema['location'] = [
1556 '@type' => 'Place',
1557 'name' => $data['site_data']['event_location']
1558 ];
1559
1560 // Add address if available
1561 if (!empty($data['site_data']['event_address'])) {
1562 $schema['location']['address'] = [
1563 '@type' => 'PostalAddress',
1564 'streetAddress' => $data['site_data']['event_address']
1565 ];
1566 }
1567 }
1568
1569 // Organizer
1570 if (!empty($data['site_data']['event_organizer'])) {
1571 $schema['organizer'] = [
1572 '@type' => 'Organization',
1573 'name' => $data['site_data']['event_organizer']
1574 ];
1575 } else {
1576 $schema['organizer'] = [
1577 '@type' => 'Organization',
1578 'name' => get_bloginfo('name'),
1579 'url' => home_url()
1580 ];
1581 }
1582
1583 // End date
1584 if (!empty($data['site_data']['event_end_date'])) {
1585 $schema['endDate'] = $data['site_data']['event_end_date'];
1586 }
1587
1588 // Optional properties
1589 if (!empty($data['site_data']['event_status'])) {
1590 $schema['eventStatus'] = 'https://schema.org/' . $data['site_data']['event_status'];
1591 }
1592
1593 if (!empty($data['site_data']['event_attendance_mode'])) {
1594 $schema['eventAttendanceMode'] = 'https://schema.org/' . $data['site_data']['event_attendance_mode'];
1595 }
1596
1597 // Offers/Tickets
1598 if (!empty($data['site_data']['event_price'])) {
1599 $schema['offers'] = [
1600 '@type' => 'Offer',
1601 'price' => $data['site_data']['event_price'],
1602 'priceCurrency' => $data['site_data']['event_currency'] ?? 'USD',
1603 'availability' => 'https://schema.org/InStock'
1604 ];
1605 }
1606
1607 // Image
1608 if (!empty($data['site_data']['event_image'])) {
1609 $schema['image'] = $this->format_image_schema($data['site_data']['event_image']);
1610 } elseif (!empty($data['image'])) {
1611 $schema['image'] = $this->format_image_schema($data['image']);
1612 }
1613
1614 // Performer - recommended property
1615 if (!empty($data['site_data']['event_performer'])) {
1616 $schema['performer'] = [
1617 '@type' => 'Person',
1618 'name' => $data['site_data']['event_performer']
1619 ];
1620 }
1621
1622 // URL
1623 if (!empty($data['url'])) {
1624 $schema['url'] = $data['url'];
1625 }
1626
1627 return $schema;
1628 }
1629
1630 /**
1631 * Populate HowTo schema
1632 * PRESERVED: Exact same method logic from original Schema_Generator
1633 *
1634 * @since 1.0.0
1635 *
1636 * @param array $schema Base schema
1637 * @param array $data Content data
1638 * @param string $context Context type
1639 * @return array Populated schema
1640 */
1641 private function populate_howto_schema(array $schema, array $data, string $context): array {
1642 // Required properties - prioritize user-configured fields
1643 $schema['name'] = $data['site_data']['howto_name'] ?? $data['title'] ?? '';
1644
1645 // Recommended properties
1646 if (!empty($data['site_data']['howto_description'])) {
1647 $schema['description'] = $this->truncate_text($data['site_data']['howto_description'], 160);
1648 } elseif (!empty($data['excerpt'])) {
1649 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1650 } elseif (!empty($data['content'])) {
1651 $schema['description'] = $this->truncate_text($data['content'], 160);
1652 }
1653
1654 // Total time
1655 if (!empty($data['site_data']['howto_total_time'])) {
1656 $schema['totalTime'] = $data['site_data']['howto_total_time'];
1657 }
1658
1659 // Optional properties
1660 if (!empty($data['site_data']['howto_prep_time'])) {
1661 $schema['prepTime'] = $data['site_data']['howto_prep_time'];
1662 }
1663
1664 if (!empty($data['site_data']['howto_difficulty'])) {
1665 $schema['difficulty'] = $data['site_data']['howto_difficulty'];
1666 }
1667
1668 if (!empty($data['site_data']['howto_estimated_cost'])) {
1669 $schema['estimatedCost'] = [
1670 '@type' => 'MonetaryAmount',
1671 'currency' => $data['site_data']['howto_currency'] ?? 'USD',
1672 'value' => $data['site_data']['howto_estimated_cost']
1673 ];
1674 }
1675
1676 // Supply/Materials
1677 if (!empty($data['site_data']['howto_supply']) && is_array($data['site_data']['howto_supply'])) {
1678 $schema['supply'] = [];
1679 foreach ($data['site_data']['howto_supply'] as $supply_item) {
1680 $schema['supply'][] = [
1681 '@type' => 'HowToSupply',
1682 'name' => $supply_item
1683 ];
1684 }
1685 }
1686
1687 // Tools
1688 if (!empty($data['site_data']['howto_tool']) && is_array($data['site_data']['howto_tool'])) {
1689 $schema['tool'] = [];
1690 foreach ($data['site_data']['howto_tool'] as $tool_item) {
1691 $schema['tool'][] = [
1692 '@type' => 'HowToTool',
1693 'name' => $tool_item
1694 ];
1695 }
1696 }
1697
1698 // Steps - handle both string and array formats
1699 if (!empty($data['site_data']['howto_steps'])) {
1700 $schema['step'] = [];
1701
1702 if (is_array($data['site_data']['howto_steps'])) {
1703 // Handle array format (structured steps)
1704 foreach ($data['site_data']['howto_steps'] as $index => $step) {
1705 $step_schema = [
1706 '@type' => 'HowToStep',
1707 'name' => $step['name'] ?? "Step " . ($index + 1),
1708 'text' => $step['text'] ?? ''
1709 ];
1710
1711 if (!empty($step['image'])) {
1712 $step_schema['image'] = $this->format_image_schema($step['image']);
1713 }
1714
1715 $schema['step'][] = $step_schema;
1716 }
1717 } elseif (is_string($data['site_data']['howto_steps'])) {
1718 // Handle string format (textarea with line breaks)
1719 $steps_text = trim($data['site_data']['howto_steps']);
1720 if (!empty($steps_text)) {
1721 $step_lines = explode("\n", $steps_text);
1722 foreach ($step_lines as $index => $step_line) {
1723 $step_line = trim($step_line);
1724 if (!empty($step_line)) {
1725 // Remove numbering if present (e.g., "1. Step text" -> "Step text")
1726 $step_text = preg_replace('/^\d+\.\s*/', '', $step_line);
1727
1728 $schema['step'][] = [
1729 '@type' => 'HowToStep',
1730 'name' => "Step " . ($index + 1),
1731 'text' => $step_text
1732 ];
1733 }
1734 }
1735 }
1736 }
1737 }
1738
1739 // Yield/Output
1740 if (!empty($data['site_data']['howto_yield'])) {
1741 $schema['yield'] = $data['site_data']['howto_yield'];
1742 }
1743
1744 // Image
1745 if (!empty($data['site_data']['howto_image'])) {
1746 $schema['image'] = $this->format_image_schema($data['site_data']['howto_image']);
1747 } elseif (!empty($data['image'])) {
1748 $schema['image'] = $this->format_image_schema($data['image']);
1749 }
1750
1751 // URL from user configuration or fallback
1752 if (!empty($data['site_data']['howto_url'])) {
1753 $schema['url'] = $data['site_data']['howto_url'];
1754 } elseif (!empty($data['url'])) {
1755 $schema['url'] = $data['url'];
1756 }
1757
1758 // Video
1759 if (!empty($data['site_data']['howto_video'])) {
1760 $schema['video'] = [
1761 '@type' => 'VideoObject',
1762 'contentUrl' => $data['site_data']['howto_video']
1763 ];
1764 }
1765
1766 return $schema;
1767 }
1768 }
1769