PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-schema-builder.php

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

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