PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 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 trunk, at includes/seo/class-schema-builder.php

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