PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
thinkrank / includes / seo / class-schema-builder.php

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

2,128 lines 80.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Schema Builder Class
4 *
5 * Handles schema markup construction and JSON-LD output formatting.
6 * Extracted from Schema_Generator to follow Single Responsibility Principle.
7 * Maintains exact same building logic and output formats as original implementation.
8 *
9 * @package ThinkRank\SEO
10 * @since 1.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\SEO;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * Schema Builder Class
24 *
25 * Constructs schema markup from data and formats JSON-LD output.
26 * Preserves all existing building logic and output formats.
27 *
28 * @since 1.0.0
29 */
30 class Schema_Builder {
31
32 /**
33 * Schema Factory instance for base structures
34 *
35 * @since 1.0.0
36 * @var Schema_Factory
37 */
38 private Schema_Factory $schema_factory;
39
40 /**
41 * Constructor
42 *
43 * @since 1.0.0
44 */
45 public function __construct() {
46 // Ensure Schema_Factory is loaded
47 if (!class_exists('ThinkRank\\SEO\\Schema_Factory')) {
48 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-factory.php';
49 }
50 $this->schema_factory = new Schema_Factory();
51 }
52
53 /**
54 * Build schema markup for specific type
55 * PRESERVED: Maintains exact same building logic from original Schema_Generator
56 *
57 * @since 1.0.0
58 *
59 * @param string $schema_type Schema type to build
60 * @param array $data Content data for schema building
61 * @param string $context Context type ('site', 'post', 'page', etc.)
62 * @return array Built schema markup
63 */
64 public function build_schema(string $schema_type, array $data, string $context): array {
65 // Normalize and validate schema type
66 $schema_type = $this->schema_factory->normalize_schema_type($schema_type);
67
68 if (!$this->schema_factory->is_supported_schema_type($schema_type)) {
69 return $this->create_error_schema('Unsupported schema type: ' . $schema_type);
70 }
71
72 // Get base schema structure
73 $schema = $this->schema_factory->create_base_schema($schema_type);
74
75 // Populate schema with data based on type
76 switch ($schema_type) {
77 case 'Article':
78 case 'BlogPosting':
79 case 'TechnicalArticle':
80 case 'NewsArticle':
81 case 'ScholarlyArticle':
82 case 'Report':
83 $schema = $this->populate_article_schema($schema, $data, $context);
84 break;
85 case 'Product':
86 $schema = $this->populate_product_schema($schema, $data, $context);
87 break;
88 case 'Organization':
89 $schema = $this->populate_organization_schema($schema, $data, $context);
90 break;
91 case 'WebSite':
92 $schema = $this->populate_website_schema($schema, $data, $context);
93 break;
94 case 'WebPage':
95 case 'AboutPage':
96 case 'ContactPage':
97 case 'ProfilePage':
98 // All four carry the same WebPage properties; only @type
99 // differs, and create_base_schema() has already set it from the
100 // requested type (#624).
101 $schema = $this->populate_webpage_schema($schema, $data, $context);
102 break;
103 case 'FAQPage':
104 $schema = $this->populate_faq_schema($schema, $data, $context);
105 break;
106 case 'LocalBusiness':
107 $schema = $this->populate_local_business_schema($schema, $data, $context);
108 break;
109 case 'Person':
110 $schema = $this->populate_person_schema($schema, $data, $context);
111 break;
112 case 'SoftwareApplication':
113 $schema = $this->populate_software_application_schema($schema, $data, $context);
114 break;
115 case 'Event':
116 $schema = $this->populate_event_schema($schema, $data, $context);
117 break;
118 case 'HowTo':
119 $schema = $this->populate_howto_schema($schema, $data, $context);
120 break;
121 case 'Review':
122 $schema = $this->populate_review_schema($schema, $data, $context);
123 break;
124 case 'VideoObject':
125 $schema = $this->populate_video_object_schema($schema, $data, $context);
126 break;
127 default:
128 $schema = $this->populate_generic_schema($schema, $data, $context);
129 break;
130 }
131
132 return $schema;
133 }
134
135 /**
136 * Get JSON-LD formatted output
137 * PRESERVED: Exact same method logic from original Schema_Generator
138 *
139 * @since 1.0.0
140 *
141 * @param array $schema Schema markup array
142 * @return string JSON-LD formatted string
143 */
144 public function get_json_ld_output(array $schema): string {
145 // Ensure proper JSON-LD structure
146 if (!isset($schema['@context'])) {
147 $schema['@context'] = $this->schema_factory->get_schema_context();
148 }
149
150 // Clean up empty values
151 $schema = $this->clean_schema_array($schema);
152
153 // Generate JSON-LD with proper formatting. Include the HEX flags so a
154 // </script> in any field is emitted as <\/script> and can't break out
155 // of the surrounding <script type="application/ld+json"> block, matching
156 // the site-wide schema output path.
157 $json_flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
158 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
159 if (defined('WP_DEBUG') && WP_DEBUG) {
160 $json_flags |= JSON_PRETTY_PRINT;
161 }
162
163 return wp_json_encode($schema, $json_flags);
164 }
165
166 /**
167 * Create error schema for unsupported types or errors
168 *
169 * @since 1.0.0
170 *
171 * @param string $error_message Error message
172 * @return array Error schema structure
173 */
174 private function create_error_schema(string $error_message): array {
175 return [
176 '@context' => $this->schema_factory->get_schema_context(),
177 '@type' => 'Thing',
178 'name' => 'Schema Generation Error',
179 'description' => $error_message,
180 '_error' => true
181 ];
182 }
183
184 /**
185 * Populate Article schema
186 * PRESERVED: Exact same method logic from original Schema_Generator
187 *
188 * @since 1.0.0
189 *
190 * @param array $schema Base schema
191 * @param array $data Content data
192 * @param string $context Context type
193 * @return array Populated schema
194 */
195 private function populate_article_schema(array $schema, array $data, string $context): array {
196 // Required properties - prioritize user-configured fields
197 $schema['headline'] = $this->truncate_text(
198 $data['site_data']['article_headline'] ?? $data['title'] ?? '',
199 110
200 );
201
202 // Author from user configuration or fallback
203 if (!empty($data['site_data']['article_author'])) {
204 $schema['author'] = [
205 '@type' => 'Person',
206 'name' => $data['site_data']['article_author']
207 ];
208 } else {
209 $schema['author'] = $this->format_author_schema($data['author'] ?? []);
210 }
211
212 // Date published - prioritize user-configured date
213 if (!empty($data['site_data']['article_date_published'])) {
214 $schema['datePublished'] = $this->to_iso8601($data['site_data']['article_date_published']);
215 } elseif (!empty($data['date'])) {
216 $schema['datePublished'] = $this->to_iso8601($data['date']);
217 } else {
218 $schema['datePublished'] = current_time('c');
219 }
220
221 // Recommended properties - prioritize user-configured fields
222 if (!empty($data['site_data']['article_description'])) {
223 $schema['description'] = $this->truncate_text($data['site_data']['article_description'], 160);
224 } elseif (!empty($data['excerpt'])) {
225 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
226 } elseif (!empty($data['content'])) {
227 $schema['description'] = $this->truncate_text($data['content'], 160);
228 }
229
230 // Image from user configuration or fallback
231 if (!empty($data['site_data']['article_image'])) {
232 $schema['image'] = $this->format_image_schema($data['site_data']['article_image']);
233 } elseif (!empty($data['image'])) {
234 $schema['image'] = $this->format_image_schema($data['image']);
235 }
236
237 // Date modified - prioritize user-configured date
238 if (!empty($data['site_data']['article_date_modified'])) {
239 $schema['dateModified'] = $this->to_iso8601($data['site_data']['article_date_modified']);
240 } elseif (!empty($data['modified'])) {
241 $schema['dateModified'] = $this->to_iso8601($data['modified']);
242 } else {
243 $schema['dateModified'] = $schema['datePublished'];
244 }
245
246 $schema['publisher'] = $this->get_organization_schema();
247
248 // URL for the article
249 if (!empty($data['url'])) {
250 $schema['url'] = $data['url'];
251 $schema['mainEntityOfPage'] = [
252 '@type' => 'WebPage',
253 '@id' => $data['url']
254 ];
255 }
256
257 // Optional properties
258 if (!empty($data['content'])) {
259 // Use frontend-calculated word count if provided (from SEO Analysis method)
260 // Otherwise fallback to backend calculation
261 $schema['wordCount'] = isset($data['word_count']) ? (int) $data['word_count'] : str_word_count(wp_strip_all_tags($data['content']));
262
263 // Use focus keywords if available, otherwise extract from content.
264 $focus_keywords = $this->resolve_focus_keywords($data);
265 if (!empty($focus_keywords)) {
266 $schema['keywords'] = array_merge($focus_keywords, $this->extract_keywords_from_content($data['content']));
267 // Remove duplicates and limit to 10
268 $schema['keywords'] = array_slice(array_unique($schema['keywords']), 0, 10);
269 } else {
270 $schema['keywords'] = $this->extract_keywords_from_content($data['content']);
271 }
272 }
273
274 return $schema;
275 }
276
277 /**
278 * Resolve the focus keyword list from schema content data.
279 *
280 * Prefers the multi-keyword array (`focus_keywords`); falls back to the
281 * legacy single/comma-separated `focus_keyword` string.
282 *
283 * @param array $data Schema content data.
284 * @return string[] Trimmed, non-empty focus keywords.
285 */
286 private function resolve_focus_keywords(array $data): array {
287 $keywords = [];
288
289 if (!empty($data['focus_keywords']) && is_array($data['focus_keywords'])) {
290 $keywords = $data['focus_keywords'];
291 } elseif (!empty($data['focus_keyword'])) {
292 $keywords = explode(',', (string) $data['focus_keyword']);
293 }
294
295 $keywords = array_map('trim', $keywords);
296
297 return array_values(array_filter($keywords, 'strlen'));
298 }
299
300 /**
301 * Populate Product schema
302 * PRESERVED: Exact same method logic from original Schema_Generator
303 *
304 * @since 1.0.0
305 *
306 * @param array $schema Base schema
307 * @param array $data Content data
308 * @param string $context Context type
309 * @return array Populated schema
310 */
311 private function populate_product_schema(array $schema, array $data, string $context): array {
312 // Required properties - prioritize user-configured fields
313 $schema['name'] = $data['site_data']['product_name'] ?? $data['title'] ?? '';
314 $schema['description'] = $data['site_data']['product_description'] ?? $data['excerpt'] ?? $data['content'] ?? '';
315
316 // Truncate description
317 if (!empty($schema['description'])) {
318 $schema['description'] = $this->truncate_text($schema['description'], 160);
319 }
320
321 // Image from user configuration or fallback
322 if (!empty($data['site_data']['product_image'])) {
323 $schema['image'] = $this->format_image_schema($data['site_data']['product_image']);
324 } elseif (!empty($data['image'])) {
325 $schema['image'] = $this->format_image_schema($data['image']);
326 }
327
328 // Brand from user configuration
329 if (!empty($data['site_data']['product_brand'])) {
330 $schema['brand'] = [
331 '@type' => 'Brand',
332 'name' => $data['site_data']['product_brand']
333 ];
334 }
335
336 // SKU from user configuration
337 if (!empty($data['site_data']['product_sku'])) {
338 $schema['sku'] = $data['site_data']['product_sku'];
339 }
340
341 // GTIN from user configuration
342 if (!empty($data['site_data']['product_gtin'])) {
343 $schema['gtin'] = $data['site_data']['product_gtin'];
344 }
345
346 // URL from user configuration or fallback
347 if (!empty($data['site_data']['product_url'])) {
348 $schema['url'] = $data['site_data']['product_url'];
349 } elseif (!empty($data['url'])) {
350 $schema['url'] = $data['url'];
351 }
352
353 // Review from user configuration - check both field name formats
354 if (!empty($data['site_data']['product_review_rating']) && !empty($data['site_data']['product_review_author'])) {
355 $schema['review'] = [
356 '@type' => 'Review',
357 'reviewRating' => [
358 '@type' => 'Rating',
359 'ratingValue' => $data['site_data']['product_review_rating'],
360 'bestRating' => '5'
361 ],
362 'author' => [
363 '@type' => 'Person',
364 'name' => $data['site_data']['product_review_author']
365 ]
366 ];
367 } elseif (!empty($data['site_data']['product_review']) && !empty($data['site_data']['product_rating_value'])) {
368 // Use form fields: product_review text and rating_value
369 $schema['review'] = [
370 '@type' => 'Review',
371 'reviewBody' => $data['site_data']['product_review'],
372 'reviewRating' => [
373 '@type' => 'Rating',
374 'ratingValue' => $data['site_data']['product_rating_value'],
375 'bestRating' => '5'
376 ],
377 'author' => [
378 '@type' => 'Person',
379 'name' => $this->first_non_empty(
380 $data['site_data']['organization_name'] ?? null,
381 get_bloginfo('name')
382 )
383 ]
384 ];
385 }
386
387 // Aggregate Rating from user configuration - check both field name formats
388 if (!empty($data['site_data']['product_aggregate_rating']) && !empty($data['site_data']['product_review_count'])) {
389 $schema['aggregateRating'] = [
390 '@type' => 'AggregateRating',
391 'ratingValue' => $data['site_data']['product_aggregate_rating'],
392 'reviewCount' => $data['site_data']['product_review_count'],
393 'bestRating' => '5'
394 ];
395 } elseif (!empty($data['site_data']['product_rating_value']) && !empty($data['site_data']['product_rating_count'])) {
396 // Use form fields: product_rating_value and product_rating_count
397 $schema['aggregateRating'] = [
398 '@type' => 'AggregateRating',
399 'ratingValue' => $data['site_data']['product_rating_value'],
400 'reviewCount' => $data['site_data']['product_rating_count'],
401 'bestRating' => '5'
402 ];
403 }
404
405 // Offers - prioritize user-configured price
406 $offers = [
407 '@type' => 'Offer',
408 'availability' => 'https://schema.org/InStock',
409 'priceCurrency' => $data['site_data']['product_currency'] ?? 'USD'
410 ];
411
412 if (!empty($data['site_data']['product_price'])) {
413 $offers['price'] = $data['site_data']['product_price'];
414 }
415
416 if (!empty($data['url'])) {
417 $offers['url'] = $data['url'];
418 }
419
420 $schema['offers'] = $offers;
421
422 // Fallback to content extraction if user fields are empty
423 if (empty($schema['name']) || empty($schema['description'])) {
424 $product_data = $this->extract_product_data($data['content'] ?? '');
425
426 if (empty($schema['name']) && !empty($product_data['name'])) {
427 $schema['name'] = $product_data['name'];
428 }
429
430 if (empty($schema['sku']) && !empty($product_data['sku'])) {
431 $schema['sku'] = $product_data['sku'];
432 }
433
434 if (empty($schema['brand']) && !empty($product_data['brand'])) {
435 $schema['brand'] = [
436 '@type' => 'Brand',
437 'name' => $product_data['brand']
438 ];
439 }
440
441 if (empty($offers['price']) && !empty($product_data['price'])) {
442 $schema['offers']['price'] = $product_data['price'];
443 }
444 }
445
446 return $schema;
447 }
448
449 /**
450 * First argument that is a non-empty string (after trimming).
451 *
452 * @since 1.17.0
453 *
454 * @param mixed ...$values Candidate values in priority order.
455 * @return string First non-empty candidate, or '' when none qualify.
456 */
457 private function first_non_empty(...$values): string {
458 foreach ($values as $value) {
459 if (is_string($value) && trim($value) !== '') {
460 return $value;
461 }
462 }
463 return '';
464 }
465
466 /**
467 * 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]) && \ThinkRank\Core\Url_Validator::is_valid($settings[$field])) {
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 && \ThinkRank\Core\Url_Validator::is_http_url($url);
1603 }
1604 )));
1605
1606 if (!empty($social_profiles)) {
1607 $schema['sameAs'] = $social_profiles;
1608 }
1609
1610 return $schema;
1611 }
1612
1613 /**
1614 * Populate generic schema for unsupported types
1615 * PRESERVED: Exact same method logic from original Schema_Generator
1616 *
1617 * @since 1.0.0
1618 *
1619 * @param array $schema Base schema
1620 * @param array $data Content data
1621 * @param string $context Context type
1622 * @return array Populated schema
1623 */
1624 private function populate_generic_schema(array $schema, array $data, string $context): array {
1625 // Basic properties that apply to most schema types
1626 if (!empty($data['title'])) {
1627 $schema['name'] = $data['title'];
1628 }
1629 if (!empty($data['excerpt'])) {
1630 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1631 }
1632 if (!empty($data['url'])) {
1633 $schema['url'] = $data['url'];
1634 }
1635
1636 return $schema;
1637 }
1638
1639 /**
1640 * Extract FAQ data from content
1641 * PRESERVED: Exact same method logic from original Schema_Generator
1642 *
1643 * @since 1.0.0
1644 *
1645 * @param string $content Content to analyze
1646 * @return array Extracted FAQ data
1647 */
1648 private function extract_faq_data(string $content): array {
1649 $faq_data = [];
1650
1651 // Look for question/answer patterns
1652 $patterns = [
1653 '/Q:\s*(.+?)\s*A:\s*(.+?)(?=Q:|$)/s',
1654 '/\?\s*(.+?)\n(.+?)(?=\?|$)/s',
1655 '/<h[1-6][^>]*>\s*(.+?)\s*<\/h[1-6]>\s*<p>\s*(.+?)\s*<\/p>/s'
1656 ];
1657
1658 foreach ($patterns as $pattern) {
1659 if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
1660 foreach ($matches as $match) {
1661 if (count($match) >= 3) {
1662 $question = trim(wp_strip_all_tags($match[1]));
1663 $answer = trim(wp_strip_all_tags($match[2]));
1664
1665 if (!empty($question) && !empty($answer)) {
1666 $faq_data[] = [
1667 'question' => $question,
1668 'answer' => $answer
1669 ];
1670 }
1671 }
1672 }
1673 break; // Use first matching pattern
1674 }
1675 }
1676
1677 return $faq_data;
1678 }
1679
1680 /**
1681 * Extract business data from content
1682 * PRESERVED: Exact same method logic from original Schema_Generator
1683 *
1684 * @since 1.0.0
1685 *
1686 * @param string $content Content to analyze
1687 * @return array Extracted business data
1688 */
1689 private function extract_business_data(string $content): array {
1690 $business_data = [];
1691
1692 // Extract phone number
1693 if (preg_match('/(\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4})/', $content, $matches)) {
1694 $business_data['phone'] = $matches[1];
1695 }
1696
1697 // Extract address (simple pattern)
1698 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)) {
1699 $business_data['address'] = $matches[1];
1700 }
1701
1702 // Extract hours (simple pattern)
1703 if (preg_match('/(Monday|Mon).*?([0-9]{1,2}:[0-9]{2}\s*(?:AM|PM|am|pm))/i', $content, $matches)) {
1704 $business_data['hours'] = ['Monday 9:00 AM - 5:00 PM']; // Simplified
1705 }
1706
1707 return $business_data;
1708 }
1709
1710 /**
1711 * Format business hours
1712 * PRESERVED: Exact same method logic from original Schema_Generator
1713 *
1714 * @since 1.0.0
1715 *
1716 * @param array $business_hours Business hours array
1717 * @return array Formatted opening hours
1718 */
1719 private function format_business_hours(array $business_hours): array {
1720 $opening_hours = [];
1721
1722 $day_mapping = [
1723 'monday' => 'Mo',
1724 'tuesday' => 'Tu',
1725 'wednesday' => 'We',
1726 'thursday' => 'Th',
1727 'friday' => 'Fr',
1728 'saturday' => 'Sa',
1729 'sunday' => 'Su'
1730 ];
1731
1732 foreach ($business_hours as $day => $hours) {
1733 // $day may be an int key when business_hours is a numerically-indexed
1734 // list (e.g. from an import/API); cast before strtolower() so it does
1735 // not throw a TypeError under strict_types.
1736 $day_code = $day_mapping[strtolower((string) $day)] ?? $day;
1737 if (!empty($hours['open']) && !empty($hours['close'])) {
1738 $opening_hours[] = "{$day_code} {$hours['open']}-{$hours['close']}";
1739 }
1740 }
1741
1742 return $opening_hours;
1743 }
1744
1745 /**
1746 * Populate SoftwareApplication schema
1747 * PRESERVED: Exact same method logic from original Schema_Generator
1748 *
1749 * @since 1.0.0
1750 *
1751 * @param array $schema Base schema
1752 * @param array $data Content data
1753 * @param string $context Context type
1754 * @return array Populated schema
1755 */
1756 private function populate_software_application_schema(array $schema, array $data, string $context): array {
1757 // Required properties - prioritize user-configured fields
1758 $schema['name'] = $data['site_data']['software_name'] ?? $data['title'] ?? get_bloginfo('name');
1759 $schema['applicationCategory'] = $data['site_data']['software_category'] ?? 'WebApplication';
1760
1761 // Recommended properties
1762 if (!empty($data['site_data']['software_description'])) {
1763 $schema['description'] = $this->truncate_text($data['site_data']['software_description'], 160);
1764 } elseif (!empty($data['excerpt'])) {
1765 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1766 } elseif (!empty($data['content'])) {
1767 $schema['description'] = $this->truncate_text($data['content'], 160);
1768 }
1769
1770 // URL from user configuration or fallback
1771 if (!empty($data['site_data']['software_url'])) {
1772 $schema['url'] = $data['site_data']['software_url'];
1773 } elseif (!empty($data['url'])) {
1774 $schema['url'] = $data['url'];
1775 } else {
1776 $schema['url'] = home_url();
1777 }
1778
1779 // Creator/Developer - use form data if available
1780 if (!empty($data['site_data']['software_creator'])) {
1781 // Default to 'Person' if creator_type is not specified (since form defaults to Person)
1782 $creator_type = $data['site_data']['software_creator_type'] ?? 'Person';
1783
1784 $schema['creator'] = [
1785 '@type' => $creator_type,
1786 'name' => $data['site_data']['software_creator']
1787 ];
1788
1789 // Add URL for Organization type
1790 if ($creator_type === 'Organization') {
1791 $schema['creator']['url'] = home_url();
1792 }
1793 } else {
1794 // Fallback to site data
1795 $schema['creator'] = [
1796 '@type' => 'Organization',
1797 'name' => $this->first_non_empty(
1798 $data['site_data']['organization_name'] ?? null,
1799 get_bloginfo('name')
1800 ),
1801 'url' => home_url()
1802 ];
1803 }
1804
1805 // Features from user configuration
1806 if (!empty($data['site_data']['software_features']) && is_array($data['site_data']['software_features'])) {
1807 $schema['features'] = $data['site_data']['software_features'];
1808 }
1809
1810 // Aggregate Rating from user configuration - check both field name formats
1811 if (!empty($data['site_data']['software_aggregate_rating']) && !empty($data['site_data']['software_review_count'])) {
1812 $schema['aggregateRating'] = [
1813 '@type' => 'AggregateRating',
1814 'ratingValue' => $data['site_data']['software_aggregate_rating'],
1815 'reviewCount' => $data['site_data']['software_review_count'],
1816 'bestRating' => '5'
1817 ];
1818 } elseif (!empty($data['site_data']['software_rating_value']) && !empty($data['site_data']['software_rating_count'])) {
1819 // Use form fields: software_rating_value and software_rating_count
1820 $schema['aggregateRating'] = [
1821 '@type' => 'AggregateRating',
1822 'ratingValue' => $data['site_data']['software_rating_value'],
1823 'reviewCount' => $data['site_data']['software_rating_count'],
1824 'bestRating' => '5'
1825 ];
1826 }
1827
1828 // Offers/Pricing
1829 if (!empty($data['site_data']['software_price'])) {
1830 // Map availability from form data
1831 $availability_mapping = [
1832 'InStock' => 'https://schema.org/InStock',
1833 'OutOfStock' => 'https://schema.org/OutOfStock',
1834 'PreOrder' => 'https://schema.org/PreOrder',
1835 'ComingSoon' => 'https://schema.org/ComingSoon'
1836 ];
1837
1838 $availability = $data['site_data']['software_availability'] ?? 'InStock';
1839 $schema_availability = $availability_mapping[$availability] ?? 'https://schema.org/InStock';
1840
1841 $schema['offers'] = [
1842 '@type' => 'Offer',
1843 'price' => $data['site_data']['software_price'],
1844 'priceCurrency' => $data['site_data']['software_currency'] ?? 'USD',
1845 'availability' => $schema_availability
1846 ];
1847
1848 // Add price valid until if provided
1849 if (!empty($data['site_data']['software_price_valid_until'])) {
1850 $schema['offers']['priceValidUntil'] = $data['site_data']['software_price_valid_until'];
1851 }
1852 }
1853
1854 // Optional properties
1855 if (!empty($data['site_data']['software_version'])) {
1856 $schema['version'] = $data['site_data']['software_version'];
1857 }
1858
1859 // Operating Systems - check both singular and plural forms
1860 if (!empty($data['site_data']['software_operating_systems'])) {
1861 $schema['operatingSystem'] = $data['site_data']['software_operating_systems'];
1862 } elseif (!empty($data['site_data']['software_operating_system'])) {
1863 $schema['operatingSystem'] = $data['site_data']['software_operating_system'];
1864 }
1865
1866 if (!empty($data['site_data']['software_download_url'])) {
1867 $schema['downloadUrl'] = $data['site_data']['software_download_url'];
1868 }
1869
1870 // Image/Screenshot
1871 if (!empty($data['site_data']['software_screenshot'])) {
1872 $schema['screenshot'] = $this->format_image_schema($data['site_data']['software_screenshot']);
1873 } elseif (!empty($data['image'])) {
1874 $schema['screenshot'] = $this->format_image_schema($data['image']);
1875 }
1876
1877 return $schema;
1878 }
1879
1880 /**
1881 * Populate Event schema
1882 * PRESERVED: Exact same method logic from original Schema_Generator
1883 *
1884 * @since 1.0.0
1885 *
1886 * @param array $schema Base schema
1887 * @param array $data Content data
1888 * @param string $context Context type
1889 * @return array Populated schema
1890 */
1891 private function populate_event_schema(array $schema, array $data, string $context): array {
1892 // Required properties - prioritize user-configured fields
1893 $schema['name'] = $data['site_data']['event_name'] ?? $data['title'] ?? '';
1894
1895 // Start date is required
1896 if (!empty($data['site_data']['event_start_date'])) {
1897 $schema['startDate'] = $data['site_data']['event_start_date'];
1898 } else {
1899 // Fallback to current date if not specified
1900 $schema['startDate'] = current_time('c');
1901 }
1902
1903 // Recommended properties
1904 if (!empty($data['site_data']['event_description'])) {
1905 $schema['description'] = $this->truncate_text($data['site_data']['event_description'], 160);
1906 } elseif (!empty($data['excerpt'])) {
1907 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
1908 } elseif (!empty($data['content'])) {
1909 $schema['description'] = $this->truncate_text($data['content'], 160);
1910 }
1911
1912 // Location
1913 if (!empty($data['site_data']['event_location'])) {
1914 $schema['location'] = [
1915 '@type' => 'Place',
1916 'name' => $data['site_data']['event_location']
1917 ];
1918
1919 // Add address if available
1920 if (!empty($data['site_data']['event_address'])) {
1921 $schema['location']['address'] = [
1922 '@type' => 'PostalAddress',
1923 'streetAddress' => $data['site_data']['event_address']
1924 ];
1925 }
1926 }
1927
1928 // Organizer
1929 if (!empty($data['site_data']['event_organizer'])) {
1930 $schema['organizer'] = [
1931 '@type' => 'Organization',
1932 'name' => $data['site_data']['event_organizer']
1933 ];
1934 } else {
1935 $schema['organizer'] = [
1936 '@type' => 'Organization',
1937 'name' => get_bloginfo('name'),
1938 'url' => home_url()
1939 ];
1940 }
1941
1942 // End date
1943 if (!empty($data['site_data']['event_end_date'])) {
1944 $schema['endDate'] = $data['site_data']['event_end_date'];
1945 }
1946
1947 // Optional properties
1948 if (!empty($data['site_data']['event_status'])) {
1949 $schema['eventStatus'] = 'https://schema.org/' . $data['site_data']['event_status'];
1950 }
1951
1952 if (!empty($data['site_data']['event_attendance_mode'])) {
1953 $schema['eventAttendanceMode'] = 'https://schema.org/' . $data['site_data']['event_attendance_mode'];
1954 }
1955
1956 // Offers/Tickets
1957 if (!empty($data['site_data']['event_price'])) {
1958 $schema['offers'] = [
1959 '@type' => 'Offer',
1960 'price' => $data['site_data']['event_price'],
1961 'priceCurrency' => $data['site_data']['event_currency'] ?? 'USD',
1962 'availability' => 'https://schema.org/InStock'
1963 ];
1964 }
1965
1966 // Image
1967 if (!empty($data['site_data']['event_image'])) {
1968 $schema['image'] = $this->format_image_schema($data['site_data']['event_image']);
1969 } elseif (!empty($data['image'])) {
1970 $schema['image'] = $this->format_image_schema($data['image']);
1971 }
1972
1973 // Performer - recommended property
1974 if (!empty($data['site_data']['event_performer'])) {
1975 $schema['performer'] = [
1976 '@type' => 'Person',
1977 'name' => $data['site_data']['event_performer']
1978 ];
1979 }
1980
1981 // URL
1982 if (!empty($data['url'])) {
1983 $schema['url'] = $data['url'];
1984 }
1985
1986 return $schema;
1987 }
1988
1989 /**
1990 * Populate HowTo schema
1991 * PRESERVED: Exact same method logic from original Schema_Generator
1992 *
1993 * @since 1.0.0
1994 *
1995 * @param array $schema Base schema
1996 * @param array $data Content data
1997 * @param string $context Context type
1998 * @return array Populated schema
1999 */
2000 private function populate_howto_schema(array $schema, array $data, string $context): array {
2001 // Required properties - prioritize user-configured fields
2002 $schema['name'] = $data['site_data']['howto_name'] ?? $data['title'] ?? '';
2003
2004 // Recommended properties
2005 if (!empty($data['site_data']['howto_description'])) {
2006 $schema['description'] = $this->truncate_text($data['site_data']['howto_description'], 160);
2007 } elseif (!empty($data['excerpt'])) {
2008 $schema['description'] = $this->truncate_text($data['excerpt'], 160);
2009 } elseif (!empty($data['content'])) {
2010 $schema['description'] = $this->truncate_text($data['content'], 160);
2011 }
2012
2013 // Total time
2014 if (!empty($data['site_data']['howto_total_time'])) {
2015 $schema['totalTime'] = $data['site_data']['howto_total_time'];
2016 }
2017
2018 // Optional properties
2019 if (!empty($data['site_data']['howto_prep_time'])) {
2020 $schema['prepTime'] = $data['site_data']['howto_prep_time'];
2021 }
2022
2023 if (!empty($data['site_data']['howto_difficulty'])) {
2024 $schema['difficulty'] = $data['site_data']['howto_difficulty'];
2025 }
2026
2027 if (!empty($data['site_data']['howto_estimated_cost'])) {
2028 $schema['estimatedCost'] = [
2029 '@type' => 'MonetaryAmount',
2030 'currency' => $data['site_data']['howto_currency'] ?? 'USD',
2031 'value' => $data['site_data']['howto_estimated_cost']
2032 ];
2033 }
2034
2035 // Supply/Materials
2036 if (!empty($data['site_data']['howto_supply']) && is_array($data['site_data']['howto_supply'])) {
2037 $schema['supply'] = [];
2038 foreach ($data['site_data']['howto_supply'] as $supply_item) {
2039 $schema['supply'][] = [
2040 '@type' => 'HowToSupply',
2041 'name' => $supply_item
2042 ];
2043 }
2044 }
2045
2046 // Tools
2047 if (!empty($data['site_data']['howto_tool']) && is_array($data['site_data']['howto_tool'])) {
2048 $schema['tool'] = [];
2049 foreach ($data['site_data']['howto_tool'] as $tool_item) {
2050 $schema['tool'][] = [
2051 '@type' => 'HowToTool',
2052 'name' => $tool_item
2053 ];
2054 }
2055 }
2056
2057 // Steps - handle both string and array formats
2058 if (!empty($data['site_data']['howto_steps'])) {
2059 $schema['step'] = [];
2060
2061 if (is_array($data['site_data']['howto_steps'])) {
2062 // Handle array format (structured steps)
2063 foreach ($data['site_data']['howto_steps'] as $index => $step) {
2064 $step_schema = [
2065 '@type' => 'HowToStep',
2066 'name' => $step['name'] ?? "Step " . ($index + 1),
2067 'text' => $step['text'] ?? ''
2068 ];
2069
2070 if (!empty($step['image'])) {
2071 $step_schema['image'] = $this->format_image_schema($step['image']);
2072 }
2073
2074 $schema['step'][] = $step_schema;
2075 }
2076 } elseif (is_string($data['site_data']['howto_steps'])) {
2077 // Handle string format (textarea with line breaks)
2078 $steps_text = trim($data['site_data']['howto_steps']);
2079 if (!empty($steps_text)) {
2080 $step_lines = explode("\n", $steps_text);
2081 foreach ($step_lines as $index => $step_line) {
2082 $step_line = trim($step_line);
2083 if (!empty($step_line)) {
2084 // Remove numbering if present (e.g., "1. Step text" -> "Step text")
2085 $step_text = preg_replace('/^\d+\.\s*/', '', $step_line);
2086
2087 $schema['step'][] = [
2088 '@type' => 'HowToStep',
2089 'name' => "Step " . ($index + 1),
2090 'text' => $step_text
2091 ];
2092 }
2093 }
2094 }
2095 }
2096 }
2097
2098 // Yield/Output
2099 if (!empty($data['site_data']['howto_yield'])) {
2100 $schema['yield'] = $data['site_data']['howto_yield'];
2101 }
2102
2103 // Image
2104 if (!empty($data['site_data']['howto_image'])) {
2105 $schema['image'] = $this->format_image_schema($data['site_data']['howto_image']);
2106 } elseif (!empty($data['image'])) {
2107 $schema['image'] = $this->format_image_schema($data['image']);
2108 }
2109
2110 // URL from user configuration or fallback
2111 if (!empty($data['site_data']['howto_url'])) {
2112 $schema['url'] = $data['site_data']['howto_url'];
2113 } elseif (!empty($data['url'])) {
2114 $schema['url'] = $data['url'];
2115 }
2116
2117 // Video
2118 if (!empty($data['site_data']['howto_video'])) {
2119 $schema['video'] = [
2120 '@type' => 'VideoObject',
2121 'contentUrl' => $data['site_data']['howto_video']
2122 ];
2123 }
2124
2125 return $schema;
2126 }
2127 }
2128