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

784 lines 24.0 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 // Only output on singular posts/pages
67 if (!is_singular()) {
68 return;
69 }
70
71 $post = get_post();
72 if (!$post) {
73 return;
74 }
75
76 $post_type = get_post_type($post);
77 if (!$post_type) {
78 return;
79 }
80
81 // Get Global SEO settings for this post type
82 $settings = $this->get_global_seo_settings($post_type);
83 if (empty($settings) || empty($settings['schema_type'])) {
84 return;
85 }
86
87 $schema_type = $settings['schema_type'];
88 $article_type = $settings['article_type'] ?? '';
89 $media_type = $settings['media_type'] ?? '';
90
91 // Generate schema markup
92 $schema = $this->generate_schema($schema_type, $article_type, $media_type, $post);
93
94 if (empty($schema)) {
95 return;
96 }
97
98 // Output the schema markup
99 $this->output_schema_markup($schema, $schema_type);
100 }
101
102 /**
103 * Get Global SEO settings for a specific post type
104 *
105 * @since 1.0.0
106 * @param string $post_type Post type
107 * @return array Settings array
108 */
109 private function get_global_seo_settings(string $post_type): array {
110 $all_settings = get_option(self::OPTION_NAME, []);
111 return $all_settings[$post_type] ?? [];
112 }
113
114 /**
115 * Generate schema markup based on schema type
116 *
117 * @since 1.0.0
118 * @param string $schema_type Schema type (e.g., 'Article', 'WebPage', 'Media')
119 * @param string $article_type Article type (e.g., 'BlogPosting', 'NewsArticle')
120 * @param string $media_type Media type (e.g., 'ImageObject', 'VideoObject')
121 * @param \WP_Post $post WordPress post object
122 * @return array Schema markup array
123 */
124 private function generate_schema(string $schema_type, string $article_type, string $media_type, \WP_Post $post): array {
125 // Determine the actual type to use based on schema_type and sub-types
126 $type = $schema_type;
127
128 // Use article_type if schema_type is 'Article' and article_type is specified
129 if ($schema_type === 'Article' && !empty($article_type)) {
130 $type = $article_type;
131 }
132
133 // Use media_type if schema_type is 'Media' and media_type is specified
134 if ($schema_type === 'Media' && !empty($media_type)) {
135 $type = $media_type;
136 }
137
138 // Generate schema based on type
139 switch ($type) {
140 case 'Article':
141 case 'BlogPosting':
142 case 'NewsArticle':
143 case 'ScholarlyArticle':
144 case 'TechArticle':
145 return $this->generate_article_schema($type, $post);
146
147 case 'WebPage':
148 case 'AboutPage':
149 case 'ContactPage':
150 case 'FAQPage':
151 case 'ProfilePage':
152 return $this->generate_webpage_schema($type, $post);
153
154 case 'ImageObject':
155 return $this->generate_image_schema($post);
156
157 case 'VideoObject':
158 return $this->generate_video_schema($post);
159
160 case 'Product':
161 return $this->generate_product_schema($post);
162
163 case 'Event':
164 return $this->generate_event_schema($post);
165
166 case 'Media':
167 // Fallback to ImageObject if Media is selected but no media_type specified
168 return $this->generate_image_schema($post);
169
170 default:
171 // Fallback to WebPage for unknown types
172 return $this->generate_webpage_schema('WebPage', $post);
173 }
174 }
175
176 /**
177 * Generate Article schema markup
178 *
179 * @since 1.0.0
180 * @param string $type Article type
181 * @param \WP_Post $post WordPress post object
182 * @return array Schema markup
183 */
184 private function generate_article_schema(string $type, \WP_Post $post): array {
185 $schema = [
186 '@context' => self::SCHEMA_CONTEXT,
187 '@type' => $type,
188 'headline' => get_the_title($post),
189 'url' => get_permalink($post),
190 'datePublished' => get_the_date('c', $post),
191 'dateModified' => get_the_modified_date('c', $post),
192 ];
193
194 // Add description
195 $excerpt = get_the_excerpt($post);
196 if (!empty($excerpt)) {
197 $schema['description'] = wp_strip_all_tags($excerpt);
198 }
199
200 // Add author
201 $author_id = $post->post_author;
202 if ($author_id) {
203 $schema['author'] = [
204 '@type' => 'Person',
205 'name' => get_the_author_meta('display_name', $author_id),
206 'url' => get_author_posts_url($author_id),
207 ];
208 }
209
210 // Add publisher (site info)
211 $schema['publisher'] = $this->get_publisher_schema();
212
213 // Add featured image if available
214 if (has_post_thumbnail($post)) {
215 $image_id = get_post_thumbnail_id($post);
216 $image_url = wp_get_attachment_image_url($image_id, 'full');
217 if ($image_url) {
218 $schema['image'] = [
219 '@type' => 'ImageObject',
220 'url' => $image_url,
221 ];
222
223 // Add image dimensions if available
224 $image_meta = wp_get_attachment_metadata($image_id);
225 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
226 $schema['image']['width'] = $image_meta['width'];
227 $schema['image']['height'] = $image_meta['height'];
228 }
229 }
230 }
231
232 // Add main entity of page
233 $schema['mainEntityOfPage'] = [
234 '@type' => 'WebPage',
235 '@id' => get_permalink($post),
236 ];
237
238 return $schema;
239 }
240
241 /**
242 * Generate WebPage schema markup
243 *
244 * @since 1.0.0
245 * @param string $type WebPage type
246 * @param \WP_Post $post WordPress post object
247 * @return array Schema markup
248 */
249 private function generate_webpage_schema(string $type, \WP_Post $post): array {
250 $schema = [
251 '@context' => self::SCHEMA_CONTEXT,
252 '@type' => $type,
253 'name' => get_the_title($post),
254 'url' => get_permalink($post),
255 'datePublished' => get_the_date('c', $post),
256 'dateModified' => get_the_modified_date('c', $post),
257 ];
258
259 // Add description
260 $excerpt = get_the_excerpt($post);
261 if (!empty($excerpt)) {
262 $schema['description'] = wp_strip_all_tags($excerpt);
263 }
264
265 // Add featured image if available
266 if (has_post_thumbnail($post)) {
267 $image_url = get_the_post_thumbnail_url($post, 'full');
268 if ($image_url) {
269 $schema['image'] = $image_url;
270 }
271 }
272
273 // Add breadcrumb reference
274 $schema['breadcrumb'] = [
275 '@type' => 'BreadcrumbList',
276 '@id' => get_permalink($post) . '#breadcrumb',
277 ];
278
279 return $schema;
280 }
281
282 /**
283 * Generate ImageObject schema markup
284 *
285 * @since 1.0.0
286 * @param \WP_Post $post WordPress post object (attachment)
287 * @return array Schema markup
288 */
289 private function generate_image_schema(\WP_Post $post): array {
290 $image_url = wp_get_attachment_url($post->ID);
291 $image_meta = wp_get_attachment_metadata($post->ID);
292
293 $schema = [
294 '@context' => self::SCHEMA_CONTEXT,
295 '@type' => 'ImageObject',
296 'contentUrl' => $image_url,
297 'url' => get_permalink($post),
298 'name' => get_the_title($post),
299 ];
300
301 // Add caption/description
302 $caption = wp_get_attachment_caption($post->ID);
303 if (!empty($caption)) {
304 $schema['caption'] = $caption;
305 $schema['description'] = $caption;
306 }
307
308 // Add dimensions
309 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
310 $schema['width'] = $image_meta['width'];
311 $schema['height'] = $image_meta['height'];
312 }
313
314 // Add upload date
315 $schema['uploadDate'] = get_the_date('c', $post);
316
317 return $schema;
318 }
319
320 /**
321 * Generate VideoObject schema markup
322 *
323 * @since 1.0.0
324 * @param \WP_Post $post WordPress post object (attachment or post with video)
325 * @return array Schema markup
326 */
327 private function generate_video_schema(\WP_Post $post): array {
328 $schema = [
329 '@context' => self::SCHEMA_CONTEXT,
330 '@type' => 'VideoObject',
331 'name' => get_the_title($post),
332 'url' => get_permalink($post),
333 ];
334
335 // Add description
336 $description = get_the_excerpt($post);
337 if (empty($description)) {
338 $caption = wp_get_attachment_caption($post->ID);
339 if (!empty($caption)) {
340 $description = $caption;
341 }
342 }
343 if (!empty($description)) {
344 $schema['description'] = wp_strip_all_tags($description);
345 }
346
347 // For video attachments, add contentUrl
348 if ($post->post_type === 'attachment') {
349 $video_url = wp_get_attachment_url($post->ID);
350 if ($video_url) {
351 $schema['contentUrl'] = $video_url;
352 }
353
354 // Add upload date
355 $schema['uploadDate'] = get_the_date('c', $post);
356 }
357
358 // Add thumbnail/poster image if available
359 if (has_post_thumbnail($post)) {
360 $thumbnail_url = get_the_post_thumbnail_url($post, 'full');
361 if ($thumbnail_url) {
362 $schema['thumbnailUrl'] = $thumbnail_url;
363 }
364 }
365
366 // Add duration if available from meta
367 $duration = get_post_meta($post->ID, '_thinkrank_video_duration', true);
368 if (!empty($duration)) {
369 $schema['duration'] = $duration; // Should be in ISO 8601 format (e.g., PT1M30S)
370 }
371
372 // Add embed URL if available from meta
373 $embed_url = get_post_meta($post->ID, '_thinkrank_video_embed_url', true);
374 if (!empty($embed_url)) {
375 $schema['embedUrl'] = $embed_url;
376 }
377
378 return $schema;
379 }
380
381 /**
382 * Generate Product schema markup
383 *
384 * Generates valid Schema.org Product markup with required and recommended properties.
385 * Supports custom meta fields and WooCommerce integration.
386 *
387 * @since 1.0.0
388 * @param \WP_Post $post WordPress post object
389 * @return array Schema markup
390 */
391 private function generate_product_schema(\WP_Post $post): array {
392 // Base Product schema with required properties
393 $schema = [
394 '@context' => self::SCHEMA_CONTEXT,
395 '@type' => 'Product',
396 'name' => get_the_title($post),
397 'url' => get_permalink($post),
398 ];
399
400 // Add description (required for valid Product schema)
401 $description = $this->get_product_description($post);
402 if (!empty($description)) {
403 $schema['description'] = $description;
404 }
405
406 // Add image (required for valid Product schema)
407 $image = $this->get_product_image($post);
408 if (!empty($image)) {
409 $schema['image'] = $image;
410 }
411
412 // Add SKU if available
413 $sku = $this->get_product_sku($post);
414 if (!empty($sku)) {
415 $schema['sku'] = $sku;
416 }
417
418 // Add brand (recommended)
419 $brand = $this->get_product_brand($post);
420 if (!empty($brand)) {
421 $schema['brand'] = [
422 '@type' => 'Brand',
423 'name' => $brand,
424 ];
425 }
426
427 // Add offers (required for valid Product schema)
428 $offers = $this->get_product_offers($post);
429 if (!empty($offers)) {
430 $schema['offers'] = $offers;
431 }
432
433 // Add aggregate rating (recommended)
434 $rating = $this->get_product_rating($post);
435 if (!empty($rating)) {
436 $schema['aggregateRating'] = $rating;
437 }
438
439 // Add reviews (recommended)
440 $reviews = $this->get_product_reviews($post);
441 if (!empty($reviews)) {
442 $schema['review'] = $reviews;
443 }
444
445 return $schema;
446 }
447
448 /**
449 * Generate Event schema markup (placeholder)
450 *
451 * @since 1.0.0
452 * @param \WP_Post $post WordPress post object
453 * @return array Schema markup
454 */
455 private function generate_event_schema(\WP_Post $post): array {
456 // Basic Event schema - can be extended based on requirements
457 return $this->generate_webpage_schema('WebPage', $post);
458 }
459
460 /**
461 * Get publisher schema (Organization or Person)
462 *
463 * @since 1.0.0
464 * @return array Publisher schema
465 */
466 private function get_publisher_schema(): array {
467 $site_name = get_bloginfo('name');
468 $site_url = home_url();
469
470 $publisher = [
471 '@type' => 'Organization',
472 'name' => $site_name,
473 'url' => $site_url,
474 ];
475
476 // Add logo if available
477 $custom_logo_id = get_theme_mod('custom_logo');
478 if ($custom_logo_id) {
479 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
480 if ($logo_url) {
481 $publisher['logo'] = [
482 '@type' => 'ImageObject',
483 'url' => $logo_url,
484 ];
485 }
486 }
487
488 return $publisher;
489 }
490
491 /**
492 * Get product description
493 *
494 * @since 1.0.0
495 * @param \WP_Post $post WordPress post object
496 * @return string Product description
497 */
498 private function get_product_description(\WP_Post $post): string {
499 // Try custom meta field first
500 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
501
502 // Fallback to excerpt or content
503 if (empty($description)) {
504 $description = get_the_excerpt($post);
505 }
506
507 if (empty($description)) {
508 $description = wp_trim_words(wp_strip_all_tags($post->post_content), 30);
509 }
510
511 return wp_strip_all_tags($description);
512 }
513
514 /**
515 * Get product image
516 *
517 * @since 1.0.0
518 * @param \WP_Post $post WordPress post object
519 * @return array|string Product image data
520 */
521 private function get_product_image(\WP_Post $post) {
522 // Try featured image first
523 if (has_post_thumbnail($post)) {
524 $image_id = get_post_thumbnail_id($post);
525 $image_url = wp_get_attachment_image_url($image_id, 'full');
526
527 if ($image_url) {
528 $image_meta = wp_get_attachment_metadata($image_id);
529
530 return [
531 '@type' => 'ImageObject',
532 'url' => $image_url,
533 'width' => $image_meta['width'] ?? null,
534 'height' => $image_meta['height'] ?? null,
535 ];
536 }
537 }
538
539 // Try custom meta field
540 $custom_image = get_post_meta($post->ID, '_thinkrank_product_image', true);
541 if (!empty($custom_image)) {
542 return $custom_image;
543 }
544
545 return '';
546 }
547
548 /**
549 * Get product SKU
550 *
551 * @since 1.0.0
552 * @param \WP_Post $post WordPress post object
553 * @return string Product SKU
554 */
555 private function get_product_sku(\WP_Post $post): string {
556 // Try custom meta field
557 $sku = get_post_meta($post->ID, '_thinkrank_product_sku', true);
558
559 // Try WooCommerce if available
560 if (empty($sku) && function_exists('wc_get_product')) {
561 $product = wc_get_product($post->ID);
562 if ($product) {
563 $sku = $product->get_sku();
564 }
565 }
566
567 return (string) $sku;
568 }
569
570 /**
571 * Get product brand
572 *
573 * @since 1.0.0
574 * @param \WP_Post $post WordPress post object
575 * @return string Product brand
576 */
577 private function get_product_brand(\WP_Post $post): string {
578 // Try custom meta field
579 $brand = get_post_meta($post->ID, '_thinkrank_product_brand', true);
580
581 // Try WooCommerce brand taxonomy if available
582 if (empty($brand) && taxonomy_exists('product_brand')) {
583 $terms = get_the_terms($post->ID, 'product_brand');
584 if (!empty($terms) && !is_wp_error($terms)) {
585 $brand = $terms[0]->name;
586 }
587 }
588
589 return (string) $brand;
590 }
591
592 /**
593 * Get product offers
594 *
595 * @since 1.0.0
596 * @param \WP_Post $post WordPress post object
597 * @return array Product offers data
598 */
599 private function get_product_offers(\WP_Post $post): array {
600 $offers = [
601 '@type' => 'Offer',
602 'url' => get_permalink($post),
603 ];
604
605 // Get price
606 $price = get_post_meta($post->ID, '_thinkrank_product_price', true);
607
608 // Try WooCommerce if available
609 if (empty($price) && function_exists('wc_get_product')) {
610 $product = wc_get_product($post->ID);
611 if ($product) {
612 $price = $product->get_price();
613 }
614 }
615
616 if (!empty($price)) {
617 $offers['price'] = (string) $price;
618 }
619
620 // Get currency
621 $currency = get_post_meta($post->ID, '_thinkrank_product_currency', true);
622
623 // Try WooCommerce currency if available
624 if (empty($currency) && function_exists('get_woocommerce_currency')) {
625 $currency = get_woocommerce_currency();
626 }
627
628 // Default to USD
629 if (empty($currency)) {
630 $currency = 'USD';
631 }
632
633 $offers['priceCurrency'] = $currency;
634
635 // Get availability
636 $availability = get_post_meta($post->ID, '_thinkrank_product_availability', true);
637
638 // Try WooCommerce if available
639 if (empty($availability) && function_exists('wc_get_product')) {
640 $product = wc_get_product($post->ID);
641 if ($product) {
642 $availability = $product->is_in_stock() ? 'InStock' : 'OutOfStock';
643 }
644 }
645
646 // Default to InStock
647 if (empty($availability)) {
648 $availability = 'InStock';
649 }
650
651 // Ensure proper schema.org URL format
652 if (strpos($availability, 'https://schema.org/') !== 0) {
653 $offers['availability'] = 'https://schema.org/' . $availability;
654 } else {
655 $offers['availability'] = $availability;
656 }
657
658 // Add price valid until if available
659 $price_valid_until = get_post_meta($post->ID, '_thinkrank_product_price_valid_until', true);
660 if (!empty($price_valid_until)) {
661 $offers['priceValidUntil'] = $price_valid_until;
662 }
663
664 return $offers;
665 }
666
667 /**
668 * Get product aggregate rating
669 *
670 * @since 1.0.0
671 * @param \WP_Post $post WordPress post object
672 * @return array Product rating data
673 */
674 private function get_product_rating(\WP_Post $post): array {
675 $rating = [];
676
677 // Try custom meta fields
678 $rating_value = get_post_meta($post->ID, '_thinkrank_product_rating_value', true);
679 $rating_count = get_post_meta($post->ID, '_thinkrank_product_rating_count', true);
680
681 // Try WooCommerce if available
682 if ((empty($rating_value) || empty($rating_count)) && function_exists('wc_get_product')) {
683 $product = wc_get_product($post->ID);
684 if ($product) {
685 $wc_rating_count = $product->get_rating_count();
686 $wc_average = $product->get_average_rating();
687
688 if ($wc_rating_count > 0 && $wc_average > 0) {
689 $rating_value = $wc_average;
690 $rating_count = $wc_rating_count;
691 }
692 }
693 }
694
695 // Only return rating if we have both value and count
696 if (!empty($rating_value) && !empty($rating_count)) {
697 $rating = [
698 '@type' => 'AggregateRating',
699 'ratingValue' => (string) $rating_value,
700 'reviewCount' => (int) $rating_count,
701 'bestRating' => '5',
702 ];
703 }
704
705 return $rating;
706 }
707
708 /**
709 * Get product reviews
710 *
711 * @since 1.0.0
712 * @param \WP_Post $post WordPress post object
713 * @return array Product reviews data
714 */
715 private function get_product_reviews(\WP_Post $post): array {
716 $reviews = [];
717
718 // Try WooCommerce reviews if available
719 if (function_exists('wc_get_product')) {
720 $product = wc_get_product($post->ID);
721 if ($product) {
722 $comments = get_comments([
723 'post_id' => $post->ID,
724 'status' => 'approve',
725 'type' => 'review',
726 'number' => 5, // Limit to 5 most recent reviews
727 ]);
728
729 foreach ($comments as $comment) {
730 $rating = get_comment_meta($comment->comment_ID, 'rating', true);
731
732 if (!empty($rating)) {
733 $reviews[] = [
734 '@type' => 'Review',
735 'reviewRating' => [
736 '@type' => 'Rating',
737 'ratingValue' => (string) $rating,
738 'bestRating' => '5',
739 ],
740 'author' => [
741 '@type' => 'Person',
742 'name' => $comment->comment_author,
743 ],
744 'reviewBody' => wp_strip_all_tags($comment->comment_content),
745 'datePublished' => get_comment_date('c', $comment),
746 ];
747 }
748 }
749 }
750 }
751
752 // Try custom meta field for manual reviews
753 if (empty($reviews)) {
754 $custom_reviews = get_post_meta($post->ID, '_thinkrank_product_reviews', true);
755 if (!empty($custom_reviews) && is_array($custom_reviews)) {
756 $reviews = $custom_reviews;
757 }
758 }
759
760 return $reviews;
761 }
762
763 /**
764 * Output schema markup as JSON-LD
765 *
766 * @since 1.0.0
767 * @param array $schema Schema data
768 * @param string $schema_type Schema type name
769 * @return void
770 */
771 private function output_schema_markup(array $schema, string $schema_type): void {
772 if (empty($schema)) {
773 return;
774 }
775
776 echo '<!-- ThinkRank Global SEO: ' . esc_html($schema_type) . ' Schema -->' . "\n";
777 echo '<script type="application/ld+json">' . "\n";
778 echo wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
779 echo '</script>' . "\n";
780 echo '<!-- /ThinkRank Global SEO: ' . esc_html($schema_type) . ' Schema -->' . "\n";
781 }
782 }
783
784