PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 58 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.14.3, at includes/seo/class-schema-builder.php

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