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

2,132 lines 80.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Schema Builder Class
4 *
5 * Handles schema markup construction and JSON-LD output formatting.
6 * Extracted from Schema_Generator to follow Single Responsibility Principle.
7 * Maintains exact same building logic and output formats as original implementation.
8 *
9 * @package ThinkRank\SEO
10 * @since 1.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\SEO;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * Schema Builder Class
24 *
25 * Constructs schema markup from data and formats JSON-LD output.
26 * Preserves all existing building logic and output formats.
27 *
28 * @since 1.0.0
29 */
30 class Schema_Builder {
31
32 /**
33 * Schema Factory instance for base structures
34 *
35 * @since 1.0.0
36 * @var Schema_Factory
37 */
38 private Schema_Factory $schema_factory;
39
40 /**
41 * Constructor
42 *
43 * @since 1.0.0
44 */
45 public function __construct() {
46 // Ensure Schema_Factory is loaded
47 if (!class_exists('ThinkRank\\SEO\\Schema_Factory')) {
48 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-factory.php';
49 }
50 $this->schema_factory = new Schema_Factory();
51 }
52
53 /**
54 * Build schema markup for specific type
55 * PRESERVED: Maintains exact same building logic from original Schema_Generator
56 *
57 * @since 1.0.0
58 *
59 * @param string $schema_type Schema type to build
60 * @param array $data Content data for schema building
61 * @param string $context Context type ('site', 'post', 'page', etc.)
62 * @return array Built schema markup
63 */
64 public function build_schema(string $schema_type, array $data, string $context): array {
65 // Normalize and validate schema type
66 $schema_type = $this->schema_factory->normalize_schema_type($schema_type);
67
68 if (!$this->schema_factory->is_supported_schema_type($schema_type)) {
69 return $this->create_error_schema('Unsupported schema type: ' . $schema_type);
70 }
71
72 // Get base schema structure
73 $schema = $this->schema_factory->create_base_schema($schema_type);
74
75 // Populate schema with data based on type
76 switch ($schema_type) {
77 case 'Article':
78 case 'BlogPosting':
79 case 'TechnicalArticle':
80 case 'NewsArticle':
81 case 'ScholarlyArticle':
82 case 'Report':
83 $schema = $this->populate_article_schema($schema, $data, $context);
84 break;
85 case 'Product':
86 $schema = $this->populate_product_schema($schema, $data, $context);
87 break;
88 case 'Organization':
89 $schema = $this->populate_organization_schema($schema, $data, $context);
90 break;
91 case 'WebSite':
92 $schema = $this->populate_website_schema($schema, $data, $context);
93 break;
94 case 'WebPage':
95 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
844 $attachment_id = attachment_url_to_postid($image_url);
845 if ($attachment_id) {
846 $image_data = wp_get_attachment_image_src($attachment_id, 'full');
847 // SVGs report 0x0 — omit the dimensions rather than emitting
848 // zeroes, which invalidate the ImageObject.
849 if ($image_data && (int) $image_data[1] > 0 && (int) $image_data[2] > 0) {
850 $image_schema['width'] = (int) $image_data[1];
851 $image_schema['height'] = (int) $image_data[2];
852 }
853 }
854
855 return $image_schema;
856 }
857
858 /**
859 * Get organization schema for publisher
860 * PRESERVED: Exact same method logic from original Schema_Generator
861 *
862 * @since 1.0.0
863 *
864 * @return array Organization schema
865 */
866 private function get_organization_schema(): array {
867 $org_schema = [
868 '@type' => 'Organization',
869 'name' => get_bloginfo('name'),
870 'url' => home_url()
871 ];
872
873 // Add logo if available
874 $custom_logo_id = get_theme_mod('custom_logo');
875 if ($custom_logo_id) {
876 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'full');
877 if ($logo_data) {
878 $org_schema['logo'] = [
879 '@type' => 'ImageObject',
880 'url' => $logo_data[0],
881 'width' => $logo_data[1],
882 'height' => $logo_data[2]
883 ];
884 }
885 }
886
887 return $org_schema;
888 }
889
890 /**
891 * Get social media profiles
892 * Enhanced to retrieve from Schema Manager organization settings
893 *
894 * @since 1.0.0
895 *
896 * @return array Social media profile URLs
897 */
898 private function get_social_profiles(): array {
899 // Reuse one manager for the whole request. This method runs from inside
900 // the foreign-settings listener, and constructing a fresh
901 // Schema_Management_System on every Organization build was what let the
902 // listener count double per save (#463). The constructor's static guard
903 // stops the doubling; this stops the needless re-construction.
904 static $schema_manager = null;
905
906 if (null === $schema_manager) {
907 $schema_manager = new \ThinkRank\SEO\Schema_Management_System();
908 }
909
910 $settings = $schema_manager->get_settings('site', null);
911
912 $social_profiles = [];
913
914 // Organization social media fields from Schema Manager
915 $social_fields = [
916 'organization_social_facebook',
917 'organization_social_twitter',
918 'organization_social_linkedin',
919 'organization_social_instagram',
920 'organization_social_youtube',
921 'organization_social_pinterest',
922 'organization_social_whatsapp',
923 'organization_social_telegram'
924 ];
925
926 foreach ($social_fields as $field) {
927 if (!empty($settings[$field]) && filter_var($settings[$field], FILTER_VALIDATE_URL)) {
928 $social_profiles[] = $settings[$field];
929 }
930 }
931
932 return $social_profiles;
933 }
934
935 /**
936 * Extract keywords from content
937 * PRESERVED: Exact same method logic from original Schema_Generator
938 *
939 * @since 1.0.0
940 *
941 * @param string $content Content to analyze
942 * @return array Extracted keywords
943 */
944 private function extract_keywords_from_content(string $content): array {
945 // Simple keyword extraction - can be enhanced with AI
946 $content = wp_strip_all_tags($content);
947 $words = str_word_count($content, 1);
948
949 // Filter out common words and short words
950 $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'];
951
952 $keywords = [];
953 foreach ($words as $word) {
954 $word = strtolower(trim($word));
955 if (strlen($word) > 3 && !in_array($word, $common_words, true)) {
956 $keywords[] = $word;
957 }
958 }
959
960 // Return top 10 most frequent keywords
961 $keyword_counts = array_count_values($keywords);
962 arsort($keyword_counts);
963 return array_slice(array_keys($keyword_counts), 0, 10);
964 }
965
966 /**
967 * Extract product data from content
968 * PRESERVED: Exact same method logic from original Schema_Generator
969 *
970 * @since 1.0.0
971 *
972 * @param string $content Content to analyze
973 * @return array Extracted product data
974 */
975 private function extract_product_data(string $content): array {
976 $product_data = [];
977
978 // Extract price
979 if (preg_match('/\$([0-9,]+\.?[0-9]*)/i', $content, $matches)) {
980 $product_data['price'] = str_replace(',', '', $matches[1]);
981 }
982
983 // Extract SKU
984 if (preg_match('/sku[:\s]+([a-z0-9\-]+)/i', $content, $matches)) {
985 $product_data['sku'] = $matches[1];
986 }
987
988 // Extract brand (simple pattern)
989 if (preg_match('/brand[:\s]+([a-z\s]+)/i', $content, $matches)) {
990 $product_data['brand'] = trim($matches[1]);
991 }
992
993 return $product_data;
994 }
995
996 /**
997 * Clean schema array by removing empty values
998 * PRESERVED: Exact same method logic from original Schema_Generator
999 *
1000 * @since 1.0.0
1001 *
1002 * @param array $schema Schema array to clean
1003 * @return array Cleaned schema array
1004 */
1005 private function clean_schema_array(array $schema): array {
1006 // Remove internal validation metadata (should not be in final output)
1007 if (isset($schema['_validation'])) {
1008 unset($schema['_validation']);
1009 }
1010 if (isset($schema['_error'])) {
1011 unset($schema['_error']);
1012 }
1013
1014 // Remove empty values recursively, but preserve critical schema fields
1015 $critical_fields = ['@context', '@type', '@id'];
1016
1017 foreach ($schema as $key => $value) {
1018 if (is_array($value)) {
1019 $schema[$key] = $this->clean_schema_array($value);
1020 if (empty($schema[$key])) {
1021 unset($schema[$key]);
1022 }
1023 } elseif (empty($value) && $value !== 0 && $value !== '0' && !in_array($key, $critical_fields, true)) {
1024 unset($schema[$key]);
1025 }
1026 }
1027
1028 return $schema;
1029 }
1030
1031 /**
1032 * Populate Website schema
1033 * PRESERVED: Exact same method logic from original Schema_Generator
1034 *
1035 * @since 1.0.0
1036 *
1037 * @param array $schema Base schema
1038 * @param array $data Content data
1039 * @param string $context Context type
1040 * @return array Populated schema
1041 */
1042 private function populate_website_schema(array $schema, array $data, string $context): array {
1043 // Required properties - prioritize user-configured Website schema fields
1044 $schema['name'] = $this->first_non_empty(
1045 $data['site_data']['website_name'] ?? '',
1046 $data['title'] ?? '',
1047 get_bloginfo('name')
1048 );
1049 $schema['url'] = $this->first_non_empty(
1050 $data['site_data']['website_url'] ?? '',
1051 $data['url'] ?? '',
1052 home_url()
1053 );
1054
1055 // Both WebSite producers have to carry this or the deployed node and the
1056 // default one disagree about the same site — the shape of failure #688
1057 // documents. The default node is generate_website_schema() (#692).
1058 $alternate_name = \ThinkRank\SEO\Site_Identity_Manager::alternate_name_for_schema(
1059 $data['site_data']['alternate_name'] ?? null
1060 );
1061 if (null !== $alternate_name) {
1062 $schema['alternateName'] = $alternate_name;
1063 }
1064
1065 // Recommended properties - prioritize user-configured Website schema description
1066 if (!empty($data['site_data']['website_description'])) {
1067 $schema['description'] = $this->truncate_text($data['site_data']['website_description'], 160);
1068 } elseif (!empty($data['content'])) {
1069 $schema['description'] = $this->truncate_text($data['content'], 160);
1070 } else {
1071 // The tagline is stored esc_html()'d by sanitize_option(), so an
1072 // ampersand in it arrives as `&amp;`.
1073 $schema['description'] = \ThinkRank\Core\Seo_Text::normalize_schema_text(
1074 (string) get_bloginfo('description')
1075 );
1076 }
1077
1078 // Author - use organization or person data
1079 if (!empty($data['site_data']['organization_name'])) {
1080 $schema['author'] = [
1081 '@type' => 'Organization',
1082 'name' => $data['site_data']['organization_name']
1083 ];
1084 } elseif (!empty($data['site_data']['person_name'])) {
1085 $schema['author'] = [
1086 '@type' => 'Person',
1087 'name' => $data['site_data']['person_name']
1088 ];
1089 } else {
1090 // Fallback to site name as organization
1091 $schema['author'] = [
1092 '@type' => 'Organization',
1093 'name' => get_bloginfo('name')
1094 ];
1095 }
1096
1097 // Publisher - enhanced with logo from Site Identity
1098 $publisher = [
1099 '@type' => 'Organization',
1100 'name' => $this->first_non_empty(
1101 $data['site_data']['organization_name'] ?? null,
1102 get_bloginfo('name')
1103 ),
1104 'url' => $this->first_non_empty(
1105 $data['site_data']['organization_url'] ?? null,
1106 home_url()
1107 )
1108 ];
1109
1110 // Add logo to publisher from Site Identity or user configuration
1111 if (!empty($data['site_data']['logo_url'])) {
1112 $publisher['logo'] = $this->format_image_schema($data['site_data']['logo_url']);
1113 } elseif (!empty($data['site_data']['organization_logo'])) {
1114 $publisher['logo'] = $this->format_image_schema($data['site_data']['organization_logo']);
1115 } else {
1116 // Fallback to WordPress custom logo
1117 $custom_logo_id = get_theme_mod('custom_logo');
1118 if ($custom_logo_id) {
1119 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'full');
1120 if ($logo_data) {
1121 $publisher['logo'] = [
1122 '@type' => 'ImageObject',
1123 'url' => $logo_data[0],
1124 'width' => $logo_data[1],
1125 'height' => $logo_data[2]
1126 ];
1127 }
1128 }
1129 }
1130
1131 $schema['publisher'] = $publisher;
1132
1133 // Search action for sitelinks search box (optional but recommended)
1134 if ($data['site_data']['website_enable_search'] ?? true) {
1135 $search_url = $this->first_non_empty(
1136 $data['site_data']['website_search_url'] ?? '',
1137 home_url('/?s={search_term_string}')
1138 );
1139 $schema['potentialAction'] = [
1140 '@type' => 'SearchAction',
1141 'target' => [
1142 '@type' => 'EntryPoint',
1143 'urlTemplate' => $search_url
1144 ],
1145 'query-input' => 'required name=search_term_string'
1146 ];
1147 }
1148
1149 // Social media profiles (sameAs)
1150 $social_profiles = $this->get_social_profiles();
1151 if (!empty($social_profiles)) {
1152 $schema['sameAs'] = $social_profiles;
1153 }
1154
1155 // Language
1156 // BCP-47, not the WP locale: schema.org expects en-US, get_locale() gives en_US (#473).
1157 $schema['inLanguage'] = get_bloginfo('language');
1158
1159 return $schema;
1160 }
1161
1162 /**
1163 * Populate WebPage schema
1164 * PRESERVED: Exact same method logic from original Schema_Generator
1165 *
1166 * @since 1.0.0
1167 *
1168 * @param array $schema Base schema
1169 * @param array $data Content data
1170 * @param string $context Context type
1171 * @return array Populated schema
1172 */
1173 private function populate_webpage_schema(array $schema, array $data, string $context): array {
1174 // Required properties
1175 $schema['name'] = $data['title'] ?? '';
1176 $schema['url'] = $data['url'] ?? '';
1177
1178 // Optional properties
1179 if (!empty($data['excerpt'])) {
1180 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1181 } elseif (!empty($data['content'])) {
1182 $schema['description'] = $this->truncate_text($data['content'], 160);
1183 }
1184
1185 if (!empty($data['date'])) {
1186 $schema['datePublished'] = $this->to_iso8601($data['date']);
1187 }
1188
1189 if (!empty($data['modified'])) {
1190 $schema['dateModified'] = $this->to_iso8601($data['modified']);
1191 }
1192
1193 $schema['isPartOf'] = [
1194 '@type' => 'WebSite',
1195 'name' => get_bloginfo('name'),
1196 'url' => home_url()
1197 ];
1198
1199 return $schema;
1200 }
1201
1202 /**
1203 * Populate FAQ schema
1204 * PRESERVED: Exact same method logic from original Schema_Generator
1205 *
1206 * @since 1.0.0
1207 *
1208 * @param array $schema Base schema
1209 * @param array $data Content data
1210 * @param string $context Context type
1211 * @return array Populated schema
1212 */
1213 private function populate_faq_schema(array $schema, array $data, string $context): array {
1214 // Use user-configured FAQ questions first, then fallback to content extraction
1215 $faq_data = [];
1216
1217 // Check for user-configured FAQ questions
1218 if (!empty($data['site_data']['faq_questions']) && is_array($data['site_data']['faq_questions'])) {
1219 foreach ($data['site_data']['faq_questions'] as $faq_item) {
1220 if (!empty($faq_item['question']) && !empty($faq_item['answer'])) {
1221 $faq_data[] = [
1222 '@type' => 'Question',
1223 'name' => $faq_item['question'],
1224 'acceptedAnswer' => [
1225 '@type' => 'Answer',
1226 'text' => $faq_item['answer']
1227 ]
1228 ];
1229 }
1230 }
1231 }
1232
1233 // Fallback to content extraction if no user-configured questions
1234 if (empty($faq_data) && !empty($data['content'])) {
1235 $extracted_faq = $this->extract_faq_data($data['content']);
1236 foreach ($extracted_faq as $faq_item) {
1237 $faq_data[] = [
1238 '@type' => 'Question',
1239 'name' => $faq_item['question'],
1240 'acceptedAnswer' => [
1241 '@type' => 'Answer',
1242 'text' => $faq_item['answer']
1243 ]
1244 ];
1245 }
1246 }
1247
1248 $schema['mainEntity'] = $faq_data;
1249
1250 // Optional properties. The FAQ form's own Page Title / Page URL fields
1251 // win over the post's title and permalink — they were collected by the
1252 // form and then never read, so typing in them changed nothing.
1253 $schema['name'] = !empty($data['site_data']['faq_page_name'])
1254 ? $data['site_data']['faq_page_name']
1255 : ($data['title'] ?? 'Frequently Asked Questions');
1256 if (!empty($data['excerpt'])) {
1257 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1258 }
1259
1260 // URL for the FAQ page
1261 if (!empty($data['site_data']['faq_page_url'])) {
1262 $schema['url'] = $data['site_data']['faq_page_url'];
1263 } elseif (!empty($data['url'])) {
1264 $schema['url'] = $data['url'];
1265 }
1266
1267 // About - recommended property
1268 if (!empty($data['site_data']['faq_page_description'])) {
1269 $schema['about'] = $data['site_data']['faq_page_description'];
1270 } elseif (!empty($data['excerpt'])) {
1271 $schema['about'] = $this->truncate_text($data['excerpt'], 160);
1272 }
1273
1274 // Author - recommended property
1275 if (!empty($data['site_data']['organization_name'])) {
1276 $schema['author'] = [
1277 '@type' => 'Organization',
1278 'name' => $data['site_data']['organization_name']
1279 ];
1280 } elseif (!empty($data['site_data']['person_name'])) {
1281 $schema['author'] = [
1282 '@type' => 'Person',
1283 'name' => $data['site_data']['person_name']
1284 ];
1285 } elseif (!empty($data['author']['name'])) {
1286 $schema['author'] = [
1287 '@type' => 'Person',
1288 'name' => $data['author']['name']
1289 ];
1290 }
1291
1292 return $schema;
1293 }
1294
1295 /**
1296 * Populate LocalBusiness schema
1297 * PRESERVED: Exact same method logic from original Schema_Generator
1298 *
1299 * @since 1.0.0
1300 *
1301 * @param array $schema Base schema
1302 * @param array $data Content data
1303 * @param string $context Context type
1304 * @return array Populated schema
1305 */
1306 private function populate_local_business_schema(array $schema, array $data, string $context): array {
1307 // Get business data from Site Identity Business Info (single source of truth)
1308 $business_data = $this->get_business_data_from_site_identity();
1309
1310 // The business type chosen in Local SEO is what this node IS. It was
1311 // read into $business_data and then never used, so every site published
1312 // "@type": "LocalBusiness" whatever it had picked, and the ~150-type
1313 // selector (#623) changed nothing but a meta tag. create_base_schema()
1314 // has already put @type in place, so reassigning keeps key order and a
1315 // site on the default publishes exactly what it did before.
1316 $schema['@type'] = \ThinkRank\Config\Local_Business_Types_Config::schema_type(
1317 $business_data['business_type'] ?? ''
1318 );
1319
1320 // Required properties - use business name from Business Info.
1321 // `name` is required for LocalBusiness, so an empty saved value must fall
1322 // through to the next source rather than emit "".
1323 $schema['name'] = $this->first_non_empty(
1324 $business_data['business_name'] ?? null,
1325 $data['site_data']['organization_name'] ?? null,
1326 $data['title'] ?? null,
1327 get_bloginfo('name')
1328 );
1329
1330 // Address is required for LocalBusiness - use Business Info data
1331 if (!empty($business_data['business_address'])) {
1332 $schema['address'] = [
1333 '@type' => 'PostalAddress',
1334 'streetAddress' => $business_data['business_address']
1335 ];
1336
1337 // Add additional address components if available
1338 if (!empty($business_data['business_city'])) {
1339 $schema['address']['addressLocality'] = $business_data['business_city'];
1340 }
1341 if (!empty($business_data['business_state'])) {
1342 $schema['address']['addressRegion'] = $business_data['business_state'];
1343 }
1344 if (!empty($business_data['business_postal_code'])) {
1345 $schema['address']['postalCode'] = $business_data['business_postal_code'];
1346 }
1347 if (!empty($business_data['business_country'])) {
1348 $schema['address']['addressCountry'] = $business_data['business_country'];
1349 }
1350 } else {
1351 // Fallback to content extraction only if no Business Info data
1352 $extracted_data = $this->extract_business_data($data['content'] ?? '');
1353 if (!empty($extracted_data['address'])) {
1354 $schema['address'] = [
1355 '@type' => 'PostalAddress',
1356 'streetAddress' => $extracted_data['address']
1357 ];
1358 }
1359 }
1360
1361 // Telephone - use Business Info phone
1362 if (!empty($business_data['business_phone'])) {
1363 $schema['telephone'] = $business_data['business_phone'];
1364 } else {
1365 // Fallback to content extraction
1366 $extracted_data = $this->extract_business_data($data['content'] ?? '');
1367 if (!empty($extracted_data['phone'])) {
1368 $schema['telephone'] = $extracted_data['phone'];
1369 }
1370 }
1371
1372 // Opening hours from Business Info
1373 if (!empty($business_data['business_hours']) && is_array($business_data['business_hours'])) {
1374 $schema['openingHours'] = $this->format_business_hours($business_data['business_hours']);
1375 } else {
1376 // Fallback to content extraction
1377 $extracted_data = $this->extract_business_data($data['content'] ?? '');
1378 if (!empty($extracted_data['hours'])) {
1379 $schema['openingHours'] = $extracted_data['hours'];
1380 }
1381 }
1382
1383 // Geo coordinates from Business Info
1384 if (!empty($business_data['business_latitude']) && !empty($business_data['business_longitude'])) {
1385 $schema['geo'] = [
1386 '@type' => 'GeoCoordinates',
1387 'latitude' => $business_data['business_latitude'],
1388 'longitude' => $business_data['business_longitude']
1389 ];
1390 }
1391
1392 // Add URL - use business website or site URL
1393 $schema['url'] = $business_data['business_website'] ?? home_url();
1394
1395 // Add description - use business description or site description
1396 if (!empty($business_data['business_description'])) {
1397 $schema['description'] = $this->truncate_text($business_data['business_description'], 160);
1398 } elseif (!empty($data['content'])) {
1399 $schema['description'] = $this->truncate_text($data['content'], 160);
1400 }
1401
1402 // Price range from Business Info
1403 if (!empty($business_data['business_price_range'])) {
1404 $schema['priceRange'] = $business_data['business_price_range'];
1405 }
1406
1407 // Price range - recommended property
1408 if (!empty($data['site_data']['business_price_range'])) {
1409 $schema['priceRange'] = $data['site_data']['business_price_range'];
1410 }
1411
1412 // Logo from Site Identity assets or WordPress custom logo
1413 if (!empty($data['site_data']['logo_url'])) {
1414 $schema['logo'] = $this->format_image_schema($data['site_data']['logo_url']);
1415 } else {
1416 // Fallback to WordPress custom logo
1417 $custom_logo_id = get_theme_mod('custom_logo');
1418 if ($custom_logo_id) {
1419 $logo_data = wp_get_attachment_image_src($custom_logo_id, 'full');
1420 if ($logo_data) {
1421 $schema['logo'] = [
1422 '@type' => 'ImageObject',
1423 'url' => $logo_data[0],
1424 'width' => $logo_data[1],
1425 'height' => $logo_data[2]
1426 ];
1427 }
1428 }
1429 }
1430
1431 // Social media profiles from Organization settings (sameAs property)
1432 $social_profiles = [];
1433 $social_fields = [
1434 'organization_social_facebook',
1435 'organization_social_twitter',
1436 'organization_social_linkedin',
1437 'organization_social_instagram',
1438 'organization_social_youtube',
1439 'organization_social_pinterest',
1440 'organization_social_whatsapp',
1441 'organization_social_telegram'
1442 ];
1443
1444 foreach ($social_fields as $field) {
1445 if (!empty($data['site_data'][$field])) {
1446 $social_profiles[] = $data['site_data'][$field];
1447 }
1448 }
1449
1450 if (!empty($social_profiles)) {
1451 $schema['sameAs'] = $social_profiles;
1452 }
1453
1454 return $schema;
1455 }
1456
1457 /**
1458 * Get business data from Site Identity Business Info (single source of truth)
1459 *
1460 * @since 1.0.0
1461 *
1462 * @return array Business data array
1463 */
1464 private function get_business_data_from_site_identity(): array {
1465 // Get Site Identity Manager
1466 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
1467 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
1468 }
1469
1470 $site_identity_manager = new \ThinkRank\SEO\Site_Identity_Manager();
1471 $settings = $site_identity_manager->get_settings('site');
1472
1473 // Only return data if local SEO is enabled
1474 if (empty($settings['local_seo_enabled'])) {
1475 return [];
1476 }
1477
1478 return [
1479 'business_name' => $settings['business_name'] ?? '',
1480 'business_address' => $settings['business_address'] ?? '',
1481 'business_city' => $settings['business_city'] ?? '',
1482 'business_state' => $settings['business_state'] ?? '',
1483 'business_postal_code' => $settings['business_postal_code'] ?? '',
1484 'business_country' => $settings['business_country'] ?? '',
1485 'business_phone' => $settings['business_phone'] ?? '',
1486 'business_email' => $settings['business_email'] ?? '',
1487 'business_hours' => $settings['business_hours'] ?? [],
1488 'business_website' => $settings['business_website'] ?? home_url(),
1489 'business_latitude' => $settings['business_latitude'] ?? '',
1490 'business_longitude' => $settings['business_longitude'] ?? '',
1491 'business_description' => $settings['business_description'] ?? $settings['site_description'] ?? '',
1492 'business_type' => $settings['business_type'] ?? 'LocalBusiness',
1493 'business_price_range' => $settings['business_price_range'] ?? ''
1494 ];
1495 }
1496
1497 /**
1498 * Populate Person schema
1499 * PRESERVED: Exact same method logic from original Schema_Generator
1500 *
1501 * @since 1.0.0
1502 *
1503 * @param array $schema Base schema
1504 * @param array $data Content data
1505 * @param string $context Context type
1506 * @return array Populated schema
1507 */
1508 private function populate_person_schema(array $schema, array $data, string $context): array {
1509 // Required properties - prioritize user-configured fields
1510 $schema['name'] = $this->first_non_empty(
1511 $data['site_data']['person_name'] ?? '',
1512 $data['author']['name'] ?? '',
1513 $data['title'] ?? ''
1514 );
1515
1516 // Image from user configuration or fallback
1517 if (!empty($data['site_data']['person_image'])) {
1518 $schema['image'] = $this->format_image_schema($data['site_data']['person_image']);
1519 } elseif (!empty($data['image'])) {
1520 $schema['image'] = $this->format_image_schema($data['image']);
1521 }
1522
1523 // URL from user configuration or fallback
1524 if (!empty($data['site_data']['person_url'])) {
1525 $schema['url'] = $data['site_data']['person_url'];
1526 } elseif (!empty($data['url'])) {
1527 $schema['url'] = $data['url'];
1528 }
1529
1530 // Job title from user configuration
1531 if (!empty($data['site_data']['person_job_title'])) {
1532 $schema['jobTitle'] = $data['site_data']['person_job_title'];
1533 }
1534
1535 // Works for organization - Fixed field name from person_organization to person_works_for
1536 if (!empty($data['site_data']['person_works_for'])) {
1537 $schema['worksFor'] = [
1538 '@type' => 'Organization',
1539 'name' => $data['site_data']['person_works_for']
1540 ];
1541 }
1542
1543 // Description from user configuration or fallback
1544 if (!empty($data['site_data']['person_description'])) {
1545 $schema['description'] = $this->truncate_text($data['site_data']['person_description'], 160);
1546 } elseif (!empty($data['excerpt'])) {
1547 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1548 } elseif (!empty($data['content'])) {
1549 $schema['description'] = $this->truncate_text($data['content'], 160);
1550 }
1551
1552 // Email from user configuration
1553 if (!empty($data['site_data']['person_email'])) {
1554 $schema['email'] = $data['site_data']['person_email'];
1555 }
1556
1557 // Telephone from user configuration
1558 if (!empty($data['site_data']['person_telephone'])) {
1559 $schema['telephone'] = $data['site_data']['person_telephone'];
1560 }
1561
1562 // Nationality from user configuration
1563 if (!empty($data['site_data']['person_nationality'])) {
1564 $schema['nationality'] = $data['site_data']['person_nationality'];
1565 }
1566
1567 // Birth date from user configuration
1568 if (!empty($data['site_data']['person_birth_date'])) {
1569 $schema['birthDate'] = $data['site_data']['person_birth_date'];
1570 }
1571
1572 // Address from user configuration
1573 if (!empty($data['site_data']['person_address'])) {
1574 $schema['address'] = $data['site_data']['person_address'];
1575 }
1576
1577 // Social media profiles - Enhanced to include user-configured sameAs
1578 $social_profiles = [];
1579
1580 // Get user-configured social profiles first
1581 if (!empty($data['site_data']['person_same_as']) && is_array($data['site_data']['person_same_as'])) {
1582 $social_profiles = array_merge($social_profiles, $data['site_data']['person_same_as']);
1583 }
1584
1585 // Add global social profiles as fallback
1586 $global_social_profiles = $this->get_social_profiles();
1587 if (!empty($global_social_profiles)) {
1588 $social_profiles = array_merge($social_profiles, $global_social_profiles);
1589 }
1590
1591 // Remove duplicates, empties and anything that is not a URL. schema.org
1592 // types sameAs as a URL, and the person social fields are free text, so
1593 // without this a typed-in note shipped as a sameAs member and made the
1594 // whole Person invalid (#480). get_social_profiles() above already
1595 // filters its own values the same way.
1596 $social_profiles = array_values(array_unique(array_filter(
1597 $social_profiles,
1598 static function ($url) {
1599 return is_string($url)
1600 && '' !== trim($url)
1601 && filter_var($url, FILTER_VALIDATE_URL)
1602 && in_array(
1603 strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)),
1604 ['http', 'https'],
1605 true
1606 );
1607 }
1608 )));
1609
1610 if (!empty($social_profiles)) {
1611 $schema['sameAs'] = $social_profiles;
1612 }
1613
1614 return $schema;
1615 }
1616
1617 /**
1618 * Populate generic schema for unsupported types
1619 * PRESERVED: Exact same method logic from original Schema_Generator
1620 *
1621 * @since 1.0.0
1622 *
1623 * @param array $schema Base schema
1624 * @param array $data Content data
1625 * @param string $context Context type
1626 * @return array Populated schema
1627 */
1628 private function populate_generic_schema(array $schema, array $data, string $context): array {
1629 // Basic properties that apply to most schema types
1630 if (!empty($data['title'])) {
1631 $schema['name'] = $data['title'];
1632 }
1633 if (!empty($data['excerpt'])) {
1634 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1635 }
1636 if (!empty($data['url'])) {
1637 $schema['url'] = $data['url'];
1638 }
1639
1640 return $schema;
1641 }
1642
1643 /**
1644 * Extract FAQ data from content
1645 * PRESERVED: Exact same method logic from original Schema_Generator
1646 *
1647 * @since 1.0.0
1648 *
1649 * @param string $content Content to analyze
1650 * @return array Extracted FAQ data
1651 */
1652 private function extract_faq_data(string $content): array {
1653 $faq_data = [];
1654
1655 // Look for question/answer patterns
1656 $patterns = [
1657 '/Q:\s*(.+?)\s*A:\s*(.+?)(?=Q:|$)/s',
1658 '/\?\s*(.+?)\n(.+?)(?=\?|$)/s',
1659 '/<h[1-6][^>]*>\s*(.+?)\s*<\/h[1-6]>\s*<p>\s*(.+?)\s*<\/p>/s'
1660 ];
1661
1662 foreach ($patterns as $pattern) {
1663 if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
1664 foreach ($matches as $match) {
1665 if (count($match) >= 3) {
1666 $question = trim(wp_strip_all_tags($match[1]));
1667 $answer = trim(wp_strip_all_tags($match[2]));
1668
1669 if (!empty($question) && !empty($answer)) {
1670 $faq_data[] = [
1671 'question' => $question,
1672 'answer' => $answer
1673 ];
1674 }
1675 }
1676 }
1677 break; // Use first matching pattern
1678 }
1679 }
1680
1681 return $faq_data;
1682 }
1683
1684 /**
1685 * Extract business 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 business data
1692 */
1693 private function extract_business_data(string $content): array {
1694 $business_data = [];
1695
1696 // Extract phone number
1697 if (preg_match('/(\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4})/', $content, $matches)) {
1698 $business_data['phone'] = $matches[1];
1699 }
1700
1701 // Extract address (simple pattern)
1702 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)) {
1703 $business_data['address'] = $matches[1];
1704 }
1705
1706 // Extract hours (simple pattern)
1707 if (preg_match('/(Monday|Mon).*?([0-9]{1,2}:[0-9]{2}\s*(?:AM|PM|am|pm))/i', $content, $matches)) {
1708 $business_data['hours'] = ['Monday 9:00 AM - 5:00 PM']; // Simplified
1709 }
1710
1711 return $business_data;
1712 }
1713
1714 /**
1715 * Format business hours
1716 * PRESERVED: Exact same method logic from original Schema_Generator
1717 *
1718 * @since 1.0.0
1719 *
1720 * @param array $business_hours Business hours array
1721 * @return array Formatted opening hours
1722 */
1723 private function format_business_hours(array $business_hours): array {
1724 $opening_hours = [];
1725
1726 $day_mapping = [
1727 'monday' => 'Mo',
1728 'tuesday' => 'Tu',
1729 'wednesday' => 'We',
1730 'thursday' => 'Th',
1731 'friday' => 'Fr',
1732 'saturday' => 'Sa',
1733 'sunday' => 'Su'
1734 ];
1735
1736 foreach ($business_hours as $day => $hours) {
1737 // $day may be an int key when business_hours is a numerically-indexed
1738 // list (e.g. from an import/API); cast before strtolower() so it does
1739 // not throw a TypeError under strict_types.
1740 $day_code = $day_mapping[strtolower((string) $day)] ?? $day;
1741 if (!empty($hours['open']) && !empty($hours['close'])) {
1742 $opening_hours[] = "{$day_code} {$hours['open']}-{$hours['close']}";
1743 }
1744 }
1745
1746 return $opening_hours;
1747 }
1748
1749 /**
1750 * Populate SoftwareApplication schema
1751 * PRESERVED: Exact same method logic from original Schema_Generator
1752 *
1753 * @since 1.0.0
1754 *
1755 * @param array $schema Base schema
1756 * @param array $data Content data
1757 * @param string $context Context type
1758 * @return array Populated schema
1759 */
1760 private function populate_software_application_schema(array $schema, array $data, string $context): array {
1761 // Required properties - prioritize user-configured fields
1762 $schema['name'] = $data['site_data']['software_name'] ?? $data['title'] ?? get_bloginfo('name');
1763 $schema['applicationCategory'] = $data['site_data']['software_category'] ?? 'WebApplication';
1764
1765 // Recommended properties
1766 if (!empty($data['site_data']['software_description'])) {
1767 $schema['description'] = $this->truncate_text($data['site_data']['software_description'], 160);
1768 } elseif (!empty($data['excerpt'])) {
1769 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1770 } elseif (!empty($data['content'])) {
1771 $schema['description'] = $this->truncate_text($data['content'], 160);
1772 }
1773
1774 // URL from user configuration or fallback
1775 if (!empty($data['site_data']['software_url'])) {
1776 $schema['url'] = $data['site_data']['software_url'];
1777 } elseif (!empty($data['url'])) {
1778 $schema['url'] = $data['url'];
1779 } else {
1780 $schema['url'] = home_url();
1781 }
1782
1783 // Creator/Developer - use form data if available
1784 if (!empty($data['site_data']['software_creator'])) {
1785 // Default to 'Person' if creator_type is not specified (since form defaults to Person)
1786 $creator_type = $data['site_data']['software_creator_type'] ?? 'Person';
1787
1788 $schema['creator'] = [
1789 '@type' => $creator_type,
1790 'name' => $data['site_data']['software_creator']
1791 ];
1792
1793 // Add URL for Organization type
1794 if ($creator_type === 'Organization') {
1795 $schema['creator']['url'] = home_url();
1796 }
1797 } else {
1798 // Fallback to site data
1799 $schema['creator'] = [
1800 '@type' => 'Organization',
1801 'name' => $this->first_non_empty(
1802 $data['site_data']['organization_name'] ?? null,
1803 get_bloginfo('name')
1804 ),
1805 'url' => home_url()
1806 ];
1807 }
1808
1809 // Features from user configuration
1810 if (!empty($data['site_data']['software_features']) && is_array($data['site_data']['software_features'])) {
1811 $schema['features'] = $data['site_data']['software_features'];
1812 }
1813
1814 // Aggregate Rating from user configuration - check both field name formats
1815 if (!empty($data['site_data']['software_aggregate_rating']) && !empty($data['site_data']['software_review_count'])) {
1816 $schema['aggregateRating'] = [
1817 '@type' => 'AggregateRating',
1818 'ratingValue' => $data['site_data']['software_aggregate_rating'],
1819 'reviewCount' => $data['site_data']['software_review_count'],
1820 'bestRating' => '5'
1821 ];
1822 } elseif (!empty($data['site_data']['software_rating_value']) && !empty($data['site_data']['software_rating_count'])) {
1823 // Use form fields: software_rating_value and software_rating_count
1824 $schema['aggregateRating'] = [
1825 '@type' => 'AggregateRating',
1826 'ratingValue' => $data['site_data']['software_rating_value'],
1827 'reviewCount' => $data['site_data']['software_rating_count'],
1828 'bestRating' => '5'
1829 ];
1830 }
1831
1832 // Offers/Pricing
1833 if (!empty($data['site_data']['software_price'])) {
1834 // Map availability from form data
1835 $availability_mapping = [
1836 'InStock' => 'https://schema.org/InStock',
1837 'OutOfStock' => 'https://schema.org/OutOfStock',
1838 'PreOrder' => 'https://schema.org/PreOrder',
1839 'ComingSoon' => 'https://schema.org/ComingSoon'
1840 ];
1841
1842 $availability = $data['site_data']['software_availability'] ?? 'InStock';
1843 $schema_availability = $availability_mapping[$availability] ?? 'https://schema.org/InStock';
1844
1845 $schema['offers'] = [
1846 '@type' => 'Offer',
1847 'price' => $data['site_data']['software_price'],
1848 'priceCurrency' => $data['site_data']['software_currency'] ?? 'USD',
1849 'availability' => $schema_availability
1850 ];
1851
1852 // Add price valid until if provided
1853 if (!empty($data['site_data']['software_price_valid_until'])) {
1854 $schema['offers']['priceValidUntil'] = $data['site_data']['software_price_valid_until'];
1855 }
1856 }
1857
1858 // Optional properties
1859 if (!empty($data['site_data']['software_version'])) {
1860 $schema['version'] = $data['site_data']['software_version'];
1861 }
1862
1863 // Operating Systems - check both singular and plural forms
1864 if (!empty($data['site_data']['software_operating_systems'])) {
1865 $schema['operatingSystem'] = $data['site_data']['software_operating_systems'];
1866 } elseif (!empty($data['site_data']['software_operating_system'])) {
1867 $schema['operatingSystem'] = $data['site_data']['software_operating_system'];
1868 }
1869
1870 if (!empty($data['site_data']['software_download_url'])) {
1871 $schema['downloadUrl'] = $data['site_data']['software_download_url'];
1872 }
1873
1874 // Image/Screenshot
1875 if (!empty($data['site_data']['software_screenshot'])) {
1876 $schema['screenshot'] = $this->format_image_schema($data['site_data']['software_screenshot']);
1877 } elseif (!empty($data['image'])) {
1878 $schema['screenshot'] = $this->format_image_schema($data['image']);
1879 }
1880
1881 return $schema;
1882 }
1883
1884 /**
1885 * Populate Event schema
1886 * PRESERVED: Exact same method logic from original Schema_Generator
1887 *
1888 * @since 1.0.0
1889 *
1890 * @param array $schema Base schema
1891 * @param array $data Content data
1892 * @param string $context Context type
1893 * @return array Populated schema
1894 */
1895 private function populate_event_schema(array $schema, array $data, string $context): array {
1896 // Required properties - prioritize user-configured fields
1897 $schema['name'] = $data['site_data']['event_name'] ?? $data['title'] ?? '';
1898
1899 // Start date is required
1900 if (!empty($data['site_data']['event_start_date'])) {
1901 $schema['startDate'] = $data['site_data']['event_start_date'];
1902 } else {
1903 // Fallback to current date if not specified
1904 $schema['startDate'] = current_time('c');
1905 }
1906
1907 // Recommended properties
1908 if (!empty($data['site_data']['event_description'])) {
1909 $schema['description'] = $this->truncate_text($data['site_data']['event_description'], 160);
1910 } elseif (!empty($data['excerpt'])) {
1911 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1912 } elseif (!empty($data['content'])) {
1913 $schema['description'] = $this->truncate_text($data['content'], 160);
1914 }
1915
1916 // Location
1917 if (!empty($data['site_data']['event_location'])) {
1918 $schema['location'] = [
1919 '@type' => 'Place',
1920 'name' => $data['site_data']['event_location']
1921 ];
1922
1923 // Add address if available
1924 if (!empty($data['site_data']['event_address'])) {
1925 $schema['location']['address'] = [
1926 '@type' => 'PostalAddress',
1927 'streetAddress' => $data['site_data']['event_address']
1928 ];
1929 }
1930 }
1931
1932 // Organizer
1933 if (!empty($data['site_data']['event_organizer'])) {
1934 $schema['organizer'] = [
1935 '@type' => 'Organization',
1936 'name' => $data['site_data']['event_organizer']
1937 ];
1938 } else {
1939 $schema['organizer'] = [
1940 '@type' => 'Organization',
1941 'name' => get_bloginfo('name'),
1942 'url' => home_url()
1943 ];
1944 }
1945
1946 // End date
1947 if (!empty($data['site_data']['event_end_date'])) {
1948 $schema['endDate'] = $data['site_data']['event_end_date'];
1949 }
1950
1951 // Optional properties
1952 if (!empty($data['site_data']['event_status'])) {
1953 $schema['eventStatus'] = 'https://schema.org/' . $data['site_data']['event_status'];
1954 }
1955
1956 if (!empty($data['site_data']['event_attendance_mode'])) {
1957 $schema['eventAttendanceMode'] = 'https://schema.org/' . $data['site_data']['event_attendance_mode'];
1958 }
1959
1960 // Offers/Tickets
1961 if (!empty($data['site_data']['event_price'])) {
1962 $schema['offers'] = [
1963 '@type' => 'Offer',
1964 'price' => $data['site_data']['event_price'],
1965 'priceCurrency' => $data['site_data']['event_currency'] ?? 'USD',
1966 'availability' => 'https://schema.org/InStock'
1967 ];
1968 }
1969
1970 // Image
1971 if (!empty($data['site_data']['event_image'])) {
1972 $schema['image'] = $this->format_image_schema($data['site_data']['event_image']);
1973 } elseif (!empty($data['image'])) {
1974 $schema['image'] = $this->format_image_schema($data['image']);
1975 }
1976
1977 // Performer - recommended property
1978 if (!empty($data['site_data']['event_performer'])) {
1979 $schema['performer'] = [
1980 '@type' => 'Person',
1981 'name' => $data['site_data']['event_performer']
1982 ];
1983 }
1984
1985 // URL
1986 if (!empty($data['url'])) {
1987 $schema['url'] = $data['url'];
1988 }
1989
1990 return $schema;
1991 }
1992
1993 /**
1994 * Populate HowTo schema
1995 * PRESERVED: Exact same method logic from original Schema_Generator
1996 *
1997 * @since 1.0.0
1998 *
1999 * @param array $schema Base schema
2000 * @param array $data Content data
2001 * @param string $context Context type
2002 * @return array Populated schema
2003 */
2004 private function populate_howto_schema(array $schema, array $data, string $context): array {
2005 // Required properties - prioritize user-configured fields
2006 $schema['name'] = $data['site_data']['howto_name'] ?? $data['title'] ?? '';
2007
2008 // Recommended properties
2009 if (!empty($data['site_data']['howto_description'])) {
2010 $schema['description'] = $this->truncate_text($data['site_data']['howto_description'], 160);
2011 } elseif (!empty($data['excerpt'])) {
2012 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
2013 } elseif (!empty($data['content'])) {
2014 $schema['description'] = $this->truncate_text($data['content'], 160);
2015 }
2016
2017 // Total time
2018 if (!empty($data['site_data']['howto_total_time'])) {
2019 $schema['totalTime'] = $data['site_data']['howto_total_time'];
2020 }
2021
2022 // Optional properties
2023 if (!empty($data['site_data']['howto_prep_time'])) {
2024 $schema['prepTime'] = $data['site_data']['howto_prep_time'];
2025 }
2026
2027 if (!empty($data['site_data']['howto_difficulty'])) {
2028 $schema['difficulty'] = $data['site_data']['howto_difficulty'];
2029 }
2030
2031 if (!empty($data['site_data']['howto_estimated_cost'])) {
2032 $schema['estimatedCost'] = [
2033 '@type' => 'MonetaryAmount',
2034 'currency' => $data['site_data']['howto_currency'] ?? 'USD',
2035 'value' => $data['site_data']['howto_estimated_cost']
2036 ];
2037 }
2038
2039 // Supply/Materials
2040 if (!empty($data['site_data']['howto_supply']) && is_array($data['site_data']['howto_supply'])) {
2041 $schema['supply'] = [];
2042 foreach ($data['site_data']['howto_supply'] as $supply_item) {
2043 $schema['supply'][] = [
2044 '@type' => 'HowToSupply',
2045 'name' => $supply_item
2046 ];
2047 }
2048 }
2049
2050 // Tools
2051 if (!empty($data['site_data']['howto_tool']) && is_array($data['site_data']['howto_tool'])) {
2052 $schema['tool'] = [];
2053 foreach ($data['site_data']['howto_tool'] as $tool_item) {
2054 $schema['tool'][] = [
2055 '@type' => 'HowToTool',
2056 'name' => $tool_item
2057 ];
2058 }
2059 }
2060
2061 // Steps - handle both string and array formats
2062 if (!empty($data['site_data']['howto_steps'])) {
2063 $schema['step'] = [];
2064
2065 if (is_array($data['site_data']['howto_steps'])) {
2066 // Handle array format (structured steps)
2067 foreach ($data['site_data']['howto_steps'] as $index => $step) {
2068 $step_schema = [
2069 '@type' => 'HowToStep',
2070 'name' => $step['name'] ?? "Step " . ($index + 1),
2071 'text' => $step['text'] ?? ''
2072 ];
2073
2074 if (!empty($step['image'])) {
2075 $step_schema['image'] = $this->format_image_schema($step['image']);
2076 }
2077
2078 $schema['step'][] = $step_schema;
2079 }
2080 } elseif (is_string($data['site_data']['howto_steps'])) {
2081 // Handle string format (textarea with line breaks)
2082 $steps_text = trim($data['site_data']['howto_steps']);
2083 if (!empty($steps_text)) {
2084 $step_lines = explode("\n", $steps_text);
2085 foreach ($step_lines as $index => $step_line) {
2086 $step_line = trim($step_line);
2087 if (!empty($step_line)) {
2088 // Remove numbering if present (e.g., "1. Step text" -> "Step text")
2089 $step_text = preg_replace('/^\d+\.\s*/', '', $step_line);
2090
2091 $schema['step'][] = [
2092 '@type' => 'HowToStep',
2093 'name' => "Step " . ($index + 1),
2094 'text' => $step_text
2095 ];
2096 }
2097 }
2098 }
2099 }
2100 }
2101
2102 // Yield/Output
2103 if (!empty($data['site_data']['howto_yield'])) {
2104 $schema['yield'] = $data['site_data']['howto_yield'];
2105 }
2106
2107 // Image
2108 if (!empty($data['site_data']['howto_image'])) {
2109 $schema['image'] = $this->format_image_schema($data['site_data']['howto_image']);
2110 } elseif (!empty($data['image'])) {
2111 $schema['image'] = $this->format_image_schema($data['image']);
2112 }
2113
2114 // URL from user configuration or fallback
2115 if (!empty($data['site_data']['howto_url'])) {
2116 $schema['url'] = $data['site_data']['howto_url'];
2117 } elseif (!empty($data['url'])) {
2118 $schema['url'] = $data['url'];
2119 }
2120
2121 // Video
2122 if (!empty($data['site_data']['howto_video'])) {
2123 $schema['video'] = [
2124 '@type' => 'VideoObject',
2125 'contentUrl' => $data['site_data']['howto_video']
2126 ];
2127 }
2128
2129 return $schema;
2130 }
2131 }
2132