PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.26.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.26.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 / frontend / class-global-seo-schema-output.php

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

945 lines 30.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Global SEO Schema Output Class
4 *
5 * Handles JSON-LD schema markup output based on Global SEO settings for different post types.
6 * Generates appropriate schema markup according to the schema_type setting configured in
7 * the Global SEO options for each post type.
8 *
9 * @package ThinkRank\Frontend
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\Frontend;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Global SEO Schema Output Class
25 *
26 * Generates and outputs JSON-LD schema markup based on Global SEO settings.
27 * Supports various schema types including WebPage, Article, BlogPosting, etc.
28 *
29 * @since 1.0.0
30 */
31 class Global_SEO_Schema_Output {
32
33 /**
34 * WordPress option name for storing global SEO settings
35 *
36 * @since 1.0.0
37 * @var string
38 */
39 private const OPTION_NAME = 'thinkrank_global_seo_settings';
40
41 /**
42 * Schema context URL
43 *
44 * @since 1.0.0
45 * @var string
46 */
47 private const SCHEMA_CONTEXT = 'https://schema.org';
48
49 /**
50 * Initialize the schema output
51 *
52 * @since 1.0.0
53 */
54 public function init(): void {
55 // Hook into wp_head to output schema markup
56 add_action('wp_head', [$this, 'output_global_seo_schema'], 15);
57 }
58
59 /**
60 * Output JSON-LD schema markup based on Global SEO settings
61 *
62 * @since 1.0.0
63 * @return void
64 */
65 public function output_global_seo_schema(): void {
66 // Archives get a CollectionPage schema instead of the per-post-type one
67 if (!is_singular()) {
68 $this->output_archive_schema();
69 return;
70 }
71
72 $post = get_post();
73 if (!$post) {
74 return;
75 }
76
77 $post_type = get_post_type($post);
78 if (!$post_type) {
79 return;
80 }
81
82 // Get Global SEO settings for this post type
83 $settings = $this->get_global_seo_settings($post_type);
84 if (empty($settings) || empty($settings['schema_type'])) {
85 return;
86 }
87
88 $schema_type = $settings['schema_type'];
89 $article_type = $settings['article_type'] ?? '';
90 $media_type = $settings['media_type'] ?? '';
91
92 // Generate schema markup
93 $schema = $this->generate_schema($schema_type, $article_type, $media_type, $post);
94
95 if (empty($schema)) {
96 return;
97 }
98
99 /**
100 * Filter the generated schema graph before output.
101 *
102 * Lets add-ons (e.g. ThinkRank Pro's WooCommerce module) enrich the
103 * schema — adding GTIN/MPN, variation offers, brand, etc. — without
104 * forking this class.
105 *
106 * @since 1.14.0
107 *
108 * @param array $schema The schema array.
109 * @param string $schema_type The configured schema type.
110 * @param \WP_Post $post The current post.
111 */
112 $schema = apply_filters('thinkrank_schema_output', $schema, $schema_type, $post);
113
114 if (empty($schema)) {
115 return;
116 }
117
118 // Output the schema markup
119 $this->output_schema_markup($schema, $schema_type);
120 }
121
122 /**
123 * Output CollectionPage schema for archive contexts.
124 *
125 * Covers the blog home, post type archives (e.g. a docs archive) and
126 * taxonomy archives. Search results, 404s and other contexts get nothing.
127 *
128 * @since 1.16.0
129 * @return void
130 */
131 private function output_archive_schema(): void {
132 $name = '';
133 $url = '';
134 $description = '';
135
136 if (is_home() && !is_front_page()) {
137 $posts_page_id = (int) get_option('page_for_posts');
138 $name = $posts_page_id ? get_the_title($posts_page_id) : __('Blog', 'thinkrank');
139 $url = $posts_page_id ? (string) get_permalink($posts_page_id) : home_url('/');
140 } elseif (is_post_type_archive()) {
141 $post_type_object = get_queried_object();
142 if (!$post_type_object instanceof \WP_Post_Type) {
143 return;
144 }
145 $name = $post_type_object->labels->name ?? $post_type_object->label;
146 $url = (string) get_post_type_archive_link($post_type_object->name);
147 $description = $post_type_object->description;
148 } elseif (is_category() || is_tag() || is_tax()) {
149 $term = get_queried_object();
150 if (!$term instanceof \WP_Term) {
151 return;
152 }
153 $term_link = get_term_link($term);
154 if (is_wp_error($term_link)) {
155 return;
156 }
157 $name = $term->name;
158 $url = $term_link;
159 $description = (string) term_description($term);
160 } else {
161 return;
162 }
163
164 if (empty($url)) {
165 return;
166 }
167
168 $schema = [
169 '@context' => self::SCHEMA_CONTEXT,
170 '@type' => 'CollectionPage',
171 'name' => $name,
172 'url' => $url,
173 'isPartOf' => [
174 '@type' => 'WebSite',
175 '@id' => home_url('/#website'),
176 'url' => home_url('/'),
177 ],
178 ];
179
180 $description = trim(wp_strip_all_tags($description));
181 if (!empty($description)) {
182 $schema['description'] = $description;
183 }
184
185 /**
186 * Filter the archive CollectionPage schema before output.
187 *
188 * @since 1.16.0
189 *
190 * @param array $schema The schema array ([] suppresses output).
191 */
192 $schema = apply_filters('thinkrank_archive_schema_output', $schema);
193
194 if (empty($schema)) {
195 return;
196 }
197
198 $this->output_schema_markup($schema, 'CollectionPage');
199 }
200
201 /**
202 * Whether ThinkRank would emit structured data for a given post type.
203 *
204 * Reflects the exact decision `output_global_seo_schema()` makes for
205 * singular views: schema is emitted when a `schema_type` resolves for the
206 * post type — either an explicit saved value or the built-in per-post-type
207 * default. Exposed so the Site SEO Analyzer can ask the output layer
208 * directly instead of re-reading a legacy option, keeping the audit and the
209 * rendered page from ever disagreeing about whether schema is configured.
210 *
211 * @since 1.23.1
212 * @param string $post_type Post type slug.
213 * @return bool True when structured data would be output for this post type.
214 */
215 public function would_output_schema(string $post_type): bool {
216 $settings = $this->get_global_seo_settings($post_type);
217
218 return !empty($settings['schema_type']);
219 }
220
221 /**
222 * Get Global SEO settings for a specific post type
223 *
224 * @since 1.0.0
225 * @param string $post_type Post type
226 * @return array Settings array
227 */
228 private function get_global_seo_settings(string $post_type): array {
229 $all_settings = get_option(self::OPTION_NAME, []);
230 $settings = $all_settings[$post_type] ?? [];
231
232 // Fall back to a sensible default schema type when nothing is saved for
233 // this post type, so structured data works out of the box on sites that
234 // never opened the Global SEO settings (e.g. migrated from Rank Math).
235 // An explicit saved schema_type always wins. Mirrors the per-post-type
236 // defaults the REST endpoint (Global_SEO_Endpoint::get_default_settings)
237 // exposes to the admin UI.
238 if (empty($settings['schema_type'])) {
239 $default = $this->get_default_schema_type($post_type);
240 if ($default !== null) {
241 $settings = array_merge($default, $settings);
242 }
243 }
244
245 return $settings;
246 }
247
248 /**
249 * Default schema type (and sub-type) for a post type when unconfigured.
250 *
251 * @since 1.15.x
252 * @param string $post_type Post type slug
253 * @return array|null ['schema_type' => ..., 'article_type' => ..., 'media_type' => ...] or null to emit nothing
254 */
255 private function get_default_schema_type(string $post_type): ?array {
256 switch ($post_type) {
257 case 'post':
258 return ['schema_type' => 'Article', 'article_type' => 'BlogPosting', 'media_type' => ''];
259 case 'page':
260 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
261 case 'attachment':
262 return ['schema_type' => 'Media', 'article_type' => '', 'media_type' => 'ImageObject'];
263 case 'product':
264 // Only claim Product schema when WooCommerce is actually present,
265 // so a generic CPT named "product" without WooCommerce still gets
266 // WebPage rather than an offers-less Product graph.
267 return class_exists('WooCommerce')
268 ? ['schema_type' => 'Product', 'article_type' => '', 'media_type' => '']
269 : ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
270 default:
271 // Public custom post types (e.g. BetterDocs `docs`) get WebPage.
272 $object = get_post_type_object($post_type);
273 if ($object && empty($object->public)) {
274 return null;
275 }
276 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
277 }
278 }
279
280 /**
281 * Generate schema markup based on schema type
282 *
283 * @since 1.0.0
284 * @param string $schema_type Schema type (e.g., 'Article', 'WebPage', 'Media')
285 * @param string $article_type Article type (e.g., 'BlogPosting', 'NewsArticle')
286 * @param string $media_type Media type (e.g., 'ImageObject', 'VideoObject')
287 * @param \WP_Post $post WordPress post object
288 * @return array Schema markup array
289 */
290 private function generate_schema(string $schema_type, string $article_type, string $media_type, \WP_Post $post): array {
291 // Determine the actual type to use based on schema_type and sub-types
292 $type = $schema_type;
293
294 // Use article_type if schema_type is 'Article' and article_type is specified
295 if ($schema_type === 'Article' && !empty($article_type)) {
296 $type = $article_type;
297 }
298
299 // Use media_type if schema_type is 'Media' and media_type is specified
300 if ($schema_type === 'Media' && !empty($media_type)) {
301 $type = $media_type;
302 }
303
304 // Generate schema based on type
305 switch ($type) {
306 case 'Article':
307 case 'BlogPosting':
308 case 'NewsArticle':
309 case 'ScholarlyArticle':
310 case 'TechArticle':
311 return $this->generate_article_schema($type, $post);
312
313 case 'WebPage':
314 case 'AboutPage':
315 case 'ContactPage':
316 case 'FAQPage':
317 case 'ProfilePage':
318 return $this->generate_webpage_schema($type, $post);
319
320 case 'ImageObject':
321 return $this->generate_image_schema($post);
322
323 case 'VideoObject':
324 return $this->generate_video_schema($post);
325
326 case 'Product':
327 return $this->generate_product_schema($post);
328
329 case 'Event':
330 return $this->generate_event_schema($post);
331
332 case 'Media':
333 // Fallback to ImageObject if Media is selected but no media_type specified
334 return $this->generate_image_schema($post);
335
336 default:
337 // Fallback to WebPage for unknown types
338 return $this->generate_webpage_schema('WebPage', $post);
339 }
340 }
341
342 /**
343 * Generate Article schema markup
344 *
345 * @since 1.0.0
346 * @param string $type Article type
347 * @param \WP_Post $post WordPress post object
348 * @return array Schema markup
349 */
350 private function generate_article_schema(string $type, \WP_Post $post): array {
351 $schema = [
352 '@context' => self::SCHEMA_CONTEXT,
353 '@type' => $type,
354 'headline' => get_the_title($post),
355 'url' => get_permalink($post),
356 'datePublished' => get_the_date('c', $post),
357 'dateModified' => get_the_modified_date('c', $post),
358 ];
359
360 // Add description
361 $excerpt = get_the_excerpt($post);
362 if (!empty($excerpt)) {
363 $schema['description'] = wp_strip_all_tags($excerpt);
364 }
365
366 // Add author
367 $author_id = $post->post_author;
368 if ($author_id) {
369 $schema['author'] = [
370 '@type' => 'Person',
371 'name' => get_the_author_meta('display_name', $author_id),
372 'url' => get_author_posts_url($author_id),
373 ];
374 }
375
376 // Add publisher (site info)
377 $schema['publisher'] = $this->get_publisher_schema();
378
379 // Add featured image if available
380 if (has_post_thumbnail($post)) {
381 $image_id = get_post_thumbnail_id($post);
382 $image_url = wp_get_attachment_image_url($image_id, 'full');
383 if ($image_url) {
384 $schema['image'] = [
385 '@type' => 'ImageObject',
386 'url' => $image_url,
387 ];
388
389 // Add image dimensions if available
390 $image_meta = wp_get_attachment_metadata($image_id);
391 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
392 $schema['image']['width'] = $image_meta['width'];
393 $schema['image']['height'] = $image_meta['height'];
394 }
395 }
396 }
397
398 // Add main entity of page
399 $schema['mainEntityOfPage'] = [
400 '@type' => 'WebPage',
401 '@id' => get_permalink($post),
402 ];
403
404 return $schema;
405 }
406
407 /**
408 * Generate WebPage schema markup
409 *
410 * @since 1.0.0
411 * @param string $type WebPage type
412 * @param \WP_Post $post WordPress post object
413 * @return array Schema markup
414 */
415 private function generate_webpage_schema(string $type, \WP_Post $post): array {
416 $schema = [
417 '@context' => self::SCHEMA_CONTEXT,
418 '@type' => $type,
419 'name' => get_the_title($post),
420 'url' => get_permalink($post),
421 'datePublished' => get_the_date('c', $post),
422 'dateModified' => get_the_modified_date('c', $post),
423 ];
424
425 // Add description
426 $excerpt = get_the_excerpt($post);
427 if (!empty($excerpt)) {
428 $schema['description'] = wp_strip_all_tags($excerpt);
429 }
430
431 // Add featured image if available
432 if (has_post_thumbnail($post)) {
433 $image_url = get_the_post_thumbnail_url($post, 'full');
434 if ($image_url) {
435 $schema['image'] = $image_url;
436 }
437 }
438
439 return $schema;
440 }
441
442 /**
443 * Generate ImageObject schema markup
444 *
445 * @since 1.0.0
446 * @param \WP_Post $post WordPress post object (attachment)
447 * @return array Schema markup
448 */
449 private function generate_image_schema(\WP_Post $post): array {
450 $image_url = wp_get_attachment_url($post->ID);
451 $image_meta = wp_get_attachment_metadata($post->ID);
452
453 $schema = [
454 '@context' => self::SCHEMA_CONTEXT,
455 '@type' => 'ImageObject',
456 'contentUrl' => $image_url,
457 'url' => get_permalink($post),
458 'name' => get_the_title($post),
459 ];
460
461 // Add caption/description
462 $caption = wp_get_attachment_caption($post->ID);
463 if (!empty($caption)) {
464 $schema['caption'] = $caption;
465 $schema['description'] = $caption;
466 }
467
468 // Add dimensions
469 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
470 $schema['width'] = $image_meta['width'];
471 $schema['height'] = $image_meta['height'];
472 }
473
474 // Add upload date
475 $schema['uploadDate'] = get_the_date('c', $post);
476
477 return $schema;
478 }
479
480 /**
481 * Generate VideoObject schema markup
482 *
483 * @since 1.0.0
484 * @param \WP_Post $post WordPress post object (attachment or post with video)
485 * @return array Schema markup
486 */
487 private function generate_video_schema(\WP_Post $post): array {
488 $schema = [
489 '@context' => self::SCHEMA_CONTEXT,
490 '@type' => 'VideoObject',
491 'name' => get_the_title($post),
492 'url' => get_permalink($post),
493 ];
494
495 // Add description
496 $description = get_the_excerpt($post);
497 if (empty($description)) {
498 $caption = wp_get_attachment_caption($post->ID);
499 if (!empty($caption)) {
500 $description = $caption;
501 }
502 }
503 if (!empty($description)) {
504 $schema['description'] = wp_strip_all_tags($description);
505 }
506
507 // For video attachments, add contentUrl
508 if ($post->post_type === 'attachment') {
509 $video_url = wp_get_attachment_url($post->ID);
510 if ($video_url) {
511 $schema['contentUrl'] = $video_url;
512 }
513
514 // Add upload date
515 $schema['uploadDate'] = get_the_date('c', $post);
516 }
517
518 // Add thumbnail/poster image if available
519 if (has_post_thumbnail($post)) {
520 $thumbnail_url = get_the_post_thumbnail_url($post, 'full');
521 if ($thumbnail_url) {
522 $schema['thumbnailUrl'] = $thumbnail_url;
523 }
524 }
525
526 // Add duration if available from meta
527 $duration = get_post_meta($post->ID, '_thinkrank_video_duration', true);
528 if (!empty($duration)) {
529 $schema['duration'] = $duration; // Should be in ISO 8601 format (e.g., PT1M30S)
530 }
531
532 // Add embed URL if available from meta
533 $embed_url = get_post_meta($post->ID, '_thinkrank_video_embed_url', true);
534 if (!empty($embed_url)) {
535 $schema['embedUrl'] = $embed_url;
536 }
537
538 return $schema;
539 }
540
541 /**
542 * Generate Product schema markup
543 *
544 * Generates valid Schema.org Product markup with required and recommended properties.
545 * Supports custom meta fields and WooCommerce integration.
546 *
547 * @since 1.0.0
548 * @param \WP_Post $post WordPress post object
549 * @return array Schema markup
550 */
551 private function generate_product_schema(\WP_Post $post): array {
552 // Base Product schema with required properties
553 $schema = [
554 '@context' => self::SCHEMA_CONTEXT,
555 '@type' => 'Product',
556 'name' => get_the_title($post),
557 'url' => get_permalink($post),
558 ];
559
560 // Add description (required for valid Product schema)
561 $description = $this->get_product_description($post);
562 if (!empty($description)) {
563 $schema['description'] = $description;
564 }
565
566 // Add image (required for valid Product schema)
567 $image = $this->get_product_image($post);
568 if (!empty($image)) {
569 $schema['image'] = $image;
570 }
571
572 // Add SKU if available
573 $sku = $this->get_product_sku($post);
574 if (!empty($sku)) {
575 $schema['sku'] = $sku;
576 }
577
578 // Add brand (recommended)
579 $brand = $this->get_product_brand($post);
580 if (!empty($brand)) {
581 $schema['brand'] = [
582 '@type' => 'Brand',
583 'name' => $brand,
584 ];
585 }
586
587 // Add offers (required for valid Product schema)
588 $offers = $this->get_product_offers($post);
589 if (!empty($offers)) {
590 $schema['offers'] = $offers;
591 }
592
593 // Add aggregate rating (recommended)
594 $rating = $this->get_product_rating($post);
595 if (!empty($rating)) {
596 $schema['aggregateRating'] = $rating;
597 }
598
599 // Add reviews (recommended)
600 $reviews = $this->get_product_reviews($post);
601 if (!empty($reviews)) {
602 $schema['review'] = $reviews;
603 }
604
605 return $schema;
606 }
607
608 /**
609 * Generate Event schema markup (placeholder)
610 *
611 * @since 1.0.0
612 * @param \WP_Post $post WordPress post object
613 * @return array Schema markup
614 */
615 private function generate_event_schema(\WP_Post $post): array {
616 // Basic Event schema - can be extended based on requirements
617 return $this->generate_webpage_schema('WebPage', $post);
618 }
619
620 /**
621 * Get publisher schema (Organization or Person)
622 *
623 * @since 1.0.0
624 * @return array Publisher schema
625 */
626 private function get_publisher_schema(): array {
627 $site_name = get_bloginfo('name');
628 $site_url = home_url();
629
630 $publisher = [
631 '@type' => 'Organization',
632 'name' => $site_name,
633 'url' => $site_url,
634 ];
635
636 // Add logo if available
637 $custom_logo_id = get_theme_mod('custom_logo');
638 if ($custom_logo_id) {
639 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
640 if ($logo_url) {
641 $publisher['logo'] = [
642 '@type' => 'ImageObject',
643 'url' => $logo_url,
644 ];
645 }
646 }
647
648 return $publisher;
649 }
650
651 /**
652 * Get product description
653 *
654 * @since 1.0.0
655 * @param \WP_Post $post WordPress post object
656 * @return string Product description
657 */
658 private function get_product_description(\WP_Post $post): string {
659 // Try custom meta field first
660 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
661
662 // Fallback to excerpt or content
663 if (empty($description)) {
664 $description = get_the_excerpt($post);
665 }
666
667 if (empty($description)) {
668 $description = wp_trim_words(wp_strip_all_tags($post->post_content), 30);
669 }
670
671 return wp_strip_all_tags($description);
672 }
673
674 /**
675 * Get product image
676 *
677 * @since 1.0.0
678 * @param \WP_Post $post WordPress post object
679 * @return array|string Product image data
680 */
681 private function get_product_image(\WP_Post $post) {
682 // Try featured image first
683 if (has_post_thumbnail($post)) {
684 $image_id = get_post_thumbnail_id($post);
685 $image_url = wp_get_attachment_image_url($image_id, 'full');
686
687 if ($image_url) {
688 $image_meta = wp_get_attachment_metadata($image_id);
689
690 return [
691 '@type' => 'ImageObject',
692 'url' => $image_url,
693 // SVGs report 0x0 — send null rather than a zero dimension.
694 'width' => !empty($image_meta['width']) ? (int) $image_meta['width'] : null,
695 'height' => !empty($image_meta['height']) ? (int) $image_meta['height'] : null,
696 ];
697 }
698 }
699
700 // Try custom meta field
701 $custom_image = get_post_meta($post->ID, '_thinkrank_product_image', true);
702 if (!empty($custom_image)) {
703 return $custom_image;
704 }
705
706 return '';
707 }
708
709 /**
710 * Get product SKU
711 *
712 * @since 1.0.0
713 * @param \WP_Post $post WordPress post object
714 * @return string Product SKU
715 */
716 private function get_product_sku(\WP_Post $post): string {
717 // Try custom meta field
718 $sku = get_post_meta($post->ID, '_thinkrank_product_sku', true);
719
720 // Try WooCommerce if available
721 if (empty($sku) && function_exists('wc_get_product')) {
722 $product = wc_get_product($post->ID);
723 if ($product) {
724 $sku = $product->get_sku();
725 }
726 }
727
728 return (string) $sku;
729 }
730
731 /**
732 * Get product brand
733 *
734 * @since 1.0.0
735 * @param \WP_Post $post WordPress post object
736 * @return string Product brand
737 */
738 private function get_product_brand(\WP_Post $post): string {
739 // Try custom meta field
740 $brand = get_post_meta($post->ID, '_thinkrank_product_brand', true);
741
742 // Try WooCommerce brand taxonomy if available
743 if (empty($brand) && taxonomy_exists('product_brand')) {
744 $terms = get_the_terms($post->ID, 'product_brand');
745 if (!empty($terms) && !is_wp_error($terms)) {
746 $brand = $terms[0]->name;
747 }
748 }
749
750 return (string) $brand;
751 }
752
753 /**
754 * Get product offers
755 *
756 * @since 1.0.0
757 * @param \WP_Post $post WordPress post object
758 * @return array Product offers data
759 */
760 private function get_product_offers(\WP_Post $post): array {
761 $offers = [
762 '@type' => 'Offer',
763 'url' => get_permalink($post),
764 ];
765
766 // Get price
767 $price = get_post_meta($post->ID, '_thinkrank_product_price', true);
768
769 // Try WooCommerce if available
770 if (empty($price) && function_exists('wc_get_product')) {
771 $product = wc_get_product($post->ID);
772 if ($product) {
773 $price = $product->get_price();
774 }
775 }
776
777 if (!empty($price)) {
778 $offers['price'] = (string) $price;
779 }
780
781 // Get currency
782 $currency = get_post_meta($post->ID, '_thinkrank_product_currency', true);
783
784 // Try WooCommerce currency if available
785 if (empty($currency) && function_exists('get_woocommerce_currency')) {
786 $currency = get_woocommerce_currency();
787 }
788
789 // Default to USD
790 if (empty($currency)) {
791 $currency = 'USD';
792 }
793
794 $offers['priceCurrency'] = $currency;
795
796 // Get availability
797 $availability = get_post_meta($post->ID, '_thinkrank_product_availability', true);
798
799 // Try WooCommerce if available
800 if (empty($availability) && function_exists('wc_get_product')) {
801 $product = wc_get_product($post->ID);
802 if ($product) {
803 $availability = $product->is_in_stock() ? 'InStock' : 'OutOfStock';
804 }
805 }
806
807 // Default to InStock
808 if (empty($availability)) {
809 $availability = 'InStock';
810 }
811
812 // Ensure proper schema.org URL format
813 if (strpos($availability, 'https://schema.org/') !== 0) {
814 $offers['availability'] = 'https://schema.org/' . $availability;
815 } else {
816 $offers['availability'] = $availability;
817 }
818
819 // Add price valid until if available
820 $price_valid_until = get_post_meta($post->ID, '_thinkrank_product_price_valid_until', true);
821 if (!empty($price_valid_until)) {
822 $offers['priceValidUntil'] = $price_valid_until;
823 }
824
825 return $offers;
826 }
827
828 /**
829 * Get product aggregate rating
830 *
831 * @since 1.0.0
832 * @param \WP_Post $post WordPress post object
833 * @return array Product rating data
834 */
835 private function get_product_rating(\WP_Post $post): array {
836 $rating = [];
837
838 // Try custom meta fields
839 $rating_value = get_post_meta($post->ID, '_thinkrank_product_rating_value', true);
840 $rating_count = get_post_meta($post->ID, '_thinkrank_product_rating_count', true);
841
842 // Try WooCommerce if available
843 if ((empty($rating_value) || empty($rating_count)) && function_exists('wc_get_product')) {
844 $product = wc_get_product($post->ID);
845 if ($product) {
846 $wc_rating_count = $product->get_rating_count();
847 $wc_average = $product->get_average_rating();
848
849 if ($wc_rating_count > 0 && $wc_average > 0) {
850 $rating_value = $wc_average;
851 $rating_count = $wc_rating_count;
852 }
853 }
854 }
855
856 // Only return rating if we have both value and count
857 if (!empty($rating_value) && !empty($rating_count)) {
858 $rating = [
859 '@type' => 'AggregateRating',
860 'ratingValue' => (string) $rating_value,
861 'reviewCount' => (int) $rating_count,
862 'bestRating' => '5',
863 ];
864 }
865
866 return $rating;
867 }
868
869 /**
870 * Get product reviews
871 *
872 * @since 1.0.0
873 * @param \WP_Post $post WordPress post object
874 * @return array Product reviews data
875 */
876 private function get_product_reviews(\WP_Post $post): array {
877 $reviews = [];
878
879 // Try WooCommerce reviews if available
880 if (function_exists('wc_get_product')) {
881 $product = wc_get_product($post->ID);
882 if ($product) {
883 $comments = get_comments([
884 'post_id' => $post->ID,
885 'status' => 'approve',
886 'type' => 'review',
887 'number' => 5, // Limit to 5 most recent reviews
888 ]);
889
890 foreach ($comments as $comment) {
891 $rating = get_comment_meta($comment->comment_ID, 'rating', true);
892
893 if (!empty($rating)) {
894 $reviews[] = [
895 '@type' => 'Review',
896 'reviewRating' => [
897 '@type' => 'Rating',
898 'ratingValue' => (string) $rating,
899 'bestRating' => '5',
900 ],
901 'author' => [
902 '@type' => 'Person',
903 'name' => $comment->comment_author,
904 ],
905 'reviewBody' => wp_strip_all_tags($comment->comment_content),
906 'datePublished' => get_comment_date('c', $comment),
907 ];
908 }
909 }
910 }
911 }
912
913 // Try custom meta field for manual reviews
914 if (empty($reviews)) {
915 $custom_reviews = get_post_meta($post->ID, '_thinkrank_product_reviews', true);
916 if (!empty($custom_reviews) && is_array($custom_reviews)) {
917 $reviews = $custom_reviews;
918 }
919 }
920
921 return $reviews;
922 }
923
924 /**
925 * Output schema markup as JSON-LD
926 *
927 * @since 1.0.0
928 * @param array $schema Schema data
929 * @param string $schema_type Schema type name
930 * @return void
931 */
932 private function output_schema_markup(array $schema, string $schema_type): void {
933 if (empty($schema)) {
934 return;
935 }
936
937 echo '<!-- ThinkRank Global SEO: ' . esc_html($schema_type) . ' Schema -->' . "\n";
938 echo '<script type="application/ld+json">' . "\n";
939 echo wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
940 echo '</script>' . "\n";
941 echo '<!-- /ThinkRank Global SEO: ' . esc_html($schema_type) . ' Schema -->' . "\n";
942 }
943 }
944
945