PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.0
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 2.0.0, at includes/seo/class-schema-builder.php

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