PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.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 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 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.12.0, at includes/seo/class-schema-builder.php

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