PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.0
2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 All 49 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 2.0.0, at includes/frontend/class-global-seo-schema-output.php

1,041 lines 34.2 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 // Register as a candidate for the page's single page-level entity. The
119 // Schema Manager's per-post deployment outranks this post-type-wide
120 // default when both describe the same page (#355).
121 $this->register_schema($schema, $schema_type, 'global_seo');
122 }
123
124 /**
125 * Output CollectionPage schema for archive contexts.
126 *
127 * Covers the blog home, post type archives (e.g. a docs archive) and
128 * taxonomy archives. Search results, 404s and other contexts get nothing.
129 *
130 * @since 1.16.0
131 * @return void
132 */
133 private function output_archive_schema(): void {
134 $name = '';
135 $url = '';
136 $description = '';
137
138 if (is_home() && !is_front_page()) {
139 $posts_page_id = (int) get_option('page_for_posts');
140 $name = $posts_page_id ? get_the_title($posts_page_id) : __('Blog', 'thinkrank');
141 $url = $posts_page_id ? (string) get_permalink($posts_page_id) : home_url('/');
142 } elseif (is_post_type_archive()) {
143 $post_type_object = get_queried_object();
144 if (!$post_type_object instanceof \WP_Post_Type) {
145 return;
146 }
147 $name = $post_type_object->labels->name ?? $post_type_object->label;
148 $url = (string) get_post_type_archive_link($post_type_object->name);
149 $description = $post_type_object->description;
150 } elseif (is_category() || is_tag() || is_tax()) {
151 $term = get_queried_object();
152 if (!$term instanceof \WP_Term) {
153 return;
154 }
155 $term_link = get_term_link($term);
156 if (is_wp_error($term_link)) {
157 return;
158 }
159 $name = $term->name;
160 $url = $term_link;
161 $description = (string) term_description($term);
162 } else {
163 return;
164 }
165
166 if (empty($url)) {
167 return;
168 }
169
170 $schema = [
171 '@context' => self::SCHEMA_CONTEXT,
172 '@type' => 'CollectionPage',
173 'name' => $name,
174 'url' => $url,
175 'isPartOf' => [
176 '@type' => 'WebSite',
177 '@id' => home_url('/#website'),
178 'url' => home_url('/'),
179 ],
180 ];
181
182 $description = trim(wp_strip_all_tags($description));
183 if (!empty($description)) {
184 $schema['description'] = $description;
185 }
186
187 /**
188 * Filter the archive CollectionPage schema before output.
189 *
190 * @since 1.16.0
191 *
192 * @param array $schema The schema array ([] suppresses output).
193 */
194 $schema = apply_filters('thinkrank_archive_schema_output', $schema);
195
196 if (empty($schema)) {
197 return;
198 }
199
200 $this->register_schema($schema, 'CollectionPage', 'global_seo');
201 }
202
203 /**
204 * Whether ThinkRank would emit structured data for a given post type.
205 *
206 * Reflects the exact decision `output_global_seo_schema()` makes for
207 * singular views: schema is emitted when a `schema_type` resolves for the
208 * post type — either an explicit saved value or the built-in per-post-type
209 * default. Exposed so the Site SEO Analyzer can ask the output layer
210 * directly instead of re-reading a legacy option, keeping the audit and the
211 * rendered page from ever disagreeing about whether schema is configured.
212 *
213 * @since 1.23.1
214 * @param string $post_type Post type slug.
215 * @return bool True when structured data would be output for this post type.
216 */
217 public function would_output_schema(string $post_type): bool {
218 $settings = $this->get_global_seo_settings($post_type);
219
220 return !empty($settings['schema_type']);
221 }
222
223 /**
224 * Get Global SEO settings for a specific post type
225 *
226 * @since 1.0.0
227 * @param string $post_type Post type
228 * @return array Settings array
229 */
230 private function get_global_seo_settings(string $post_type): array {
231 $all_settings = get_option(self::OPTION_NAME, []);
232 $settings = $all_settings[$post_type] ?? [];
233
234 // Fall back to a sensible default schema type when nothing is saved for
235 // this post type, so structured data works out of the box on sites that
236 // never opened the Global SEO settings (e.g. migrated from Rank Math).
237 // An explicit saved schema_type always wins. Mirrors the per-post-type
238 // defaults the REST endpoint (Global_SEO_Endpoint::get_default_settings)
239 // exposes to the admin UI.
240 if (empty($settings['schema_type'])) {
241 $default = $this->get_default_schema_type($post_type);
242 if ($default !== null) {
243 $settings = array_merge($default, $settings);
244 }
245 }
246
247 return $settings;
248 }
249
250 /**
251 * Default schema type (and sub-type) for a post type when unconfigured.
252 *
253 * @since 1.15.x
254 * @param string $post_type Post type slug
255 * @return array|null ['schema_type' => ..., 'article_type' => ..., 'media_type' => ...] or null to emit nothing
256 */
257 private function get_default_schema_type(string $post_type): ?array {
258 switch ($post_type) {
259 case 'post':
260 return ['schema_type' => 'Article', 'article_type' => 'BlogPosting', 'media_type' => ''];
261 case 'page':
262 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
263 case 'attachment':
264 return ['schema_type' => 'Media', 'article_type' => '', 'media_type' => 'ImageObject'];
265 case 'product':
266 // Only claim Product schema when WooCommerce is actually present,
267 // so a generic CPT named "product" without WooCommerce still gets
268 // WebPage rather than an offers-less Product graph.
269 return class_exists('WooCommerce')
270 ? ['schema_type' => 'Product', 'article_type' => '', 'media_type' => '']
271 : ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
272 default:
273 // Public custom post types (e.g. BetterDocs `docs`) get WebPage.
274 $object = get_post_type_object($post_type);
275 if ($object && empty($object->public)) {
276 return null;
277 }
278 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
279 }
280 }
281
282 /**
283 * Generate schema markup based on schema type
284 *
285 * @since 1.0.0
286 * @param string $schema_type Schema type (e.g., 'Article', 'WebPage', 'Media')
287 * @param string $article_type Article type (e.g., 'BlogPosting', 'NewsArticle')
288 * @param string $media_type Media type (e.g., 'ImageObject', 'VideoObject')
289 * @param \WP_Post $post WordPress post object
290 * @return array Schema markup array
291 */
292 private function generate_schema(string $schema_type, string $article_type, string $media_type, \WP_Post $post): array {
293 // Determine the actual type to use based on schema_type and sub-types
294 $type = $schema_type;
295
296 // Use article_type if schema_type is 'Article' and article_type is specified
297 if ($schema_type === 'Article' && !empty($article_type)) {
298 $type = $article_type;
299 }
300
301 // Use media_type if schema_type is 'Media' and media_type is specified
302 if ($schema_type === 'Media' && !empty($media_type)) {
303 $type = $media_type;
304 }
305
306 // Generate schema based on type
307 switch ($type) {
308 case 'Article':
309 case 'BlogPosting':
310 case 'NewsArticle':
311 case 'ScholarlyArticle':
312 case 'TechArticle':
313 return $this->generate_article_schema($type, $post);
314
315 case 'FAQPage':
316 return $this->generate_faq_schema($post);
317
318 case 'WebPage':
319 case 'AboutPage':
320 case 'ContactPage':
321 case 'ProfilePage':
322 return $this->generate_webpage_schema($type, $post);
323
324 case 'ImageObject':
325 return $this->generate_image_schema($post);
326
327 case 'VideoObject':
328 return $this->generate_video_schema($post);
329
330 case 'Product':
331 return $this->generate_product_schema($post);
332
333 case 'Event':
334 return $this->generate_event_schema($post);
335
336 case 'Media':
337 // Fallback to ImageObject if Media is selected but no media_type specified
338 return $this->generate_image_schema($post);
339
340 default:
341 // Fallback to WebPage for unknown types
342 return $this->generate_webpage_schema('WebPage', $post);
343 }
344 }
345
346 /**
347 * Generate Article schema markup
348 *
349 * @since 1.0.0
350 * @param string $type Article type
351 * @param \WP_Post $post WordPress post object
352 * @return array Schema markup
353 */
354 private function generate_article_schema(string $type, \WP_Post $post): array {
355 $schema = [
356 '@context' => self::SCHEMA_CONTEXT,
357 '@type' => $type,
358 'headline' => get_the_title($post),
359 'url' => get_permalink($post),
360 'datePublished' => get_the_date('c', $post),
361 'dateModified' => get_the_modified_date('c', $post),
362 ];
363
364 // Add description
365 $excerpt = get_the_excerpt($post);
366 if (!empty($excerpt)) {
367 $schema['description'] = wp_strip_all_tags($excerpt);
368 }
369
370 // Add author
371 $author_id = $post->post_author;
372 if ($author_id) {
373 $schema['author'] = [
374 '@type' => 'Person',
375 'name' => get_the_author_meta('display_name', $author_id),
376 'url' => get_author_posts_url($author_id),
377 ];
378 }
379
380 // Add publisher (site info)
381 $schema['publisher'] = $this->get_publisher_schema();
382
383 // Add featured image if available
384 if (has_post_thumbnail($post)) {
385 $image_id = get_post_thumbnail_id($post);
386 $image_url = wp_get_attachment_image_url($image_id, 'full');
387 if ($image_url) {
388 $schema['image'] = [
389 '@type' => 'ImageObject',
390 'url' => $image_url,
391 ];
392
393 // Add image dimensions if available
394 $image_meta = wp_get_attachment_metadata($image_id);
395 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
396 $schema['image']['width'] = $image_meta['width'];
397 $schema['image']['height'] = $image_meta['height'];
398 }
399 }
400 }
401
402 // Add main entity of page
403 $schema['mainEntityOfPage'] = [
404 '@type' => 'WebPage',
405 '@id' => get_permalink($post),
406 ];
407
408 return $schema;
409 }
410
411 /**
412 * Generate FAQPage schema markup
413 *
414 * FAQPage previously fell through to generate_webpage_schema(), which emits a
415 * WebPage-shaped object labelled @type FAQPage with no mainEntity — invalid for
416 * rich results. Delegate to Schema_Builder instead, which owns the FAQ question
417 * extraction already used by the deploy path, rather than growing a second
418 * FAQ implementation here.
419 *
420 * Unlike the deploy path, this runs automatically on every post of the type with
421 * no human reviewing the result, so questions that don't actually read as
422 * questions are dropped and a page with none left falls back to WebPage — an
423 * FAQPage with an empty mainEntity is worse than a valid WebPage.
424 *
425 * @since 1.32.0
426 * @param \WP_Post $post WordPress post object
427 * @return array Schema markup
428 */
429 private function generate_faq_schema(\WP_Post $post): array {
430 if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) {
431 $builder_file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php';
432 if (!file_exists($builder_file)) {
433 return $this->generate_webpage_schema('WebPage', $post);
434 }
435 require_once $builder_file;
436 }
437
438 $excerpt = get_the_excerpt($post);
439
440 $builder = new \ThinkRank\SEO\Schema_Builder();
441 $schema = $builder->build_schema(
442 'FAQPage',
443 [
444 'title' => get_the_title($post),
445 'content' => $post->post_content,
446 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '',
447 'url' => get_permalink($post),
448 ],
449 get_post_type($post) === 'page' ? 'page' : 'post'
450 );
451
452 if (!empty($schema['_error'])) {
453 return $this->generate_webpage_schema('WebPage', $post);
454 }
455
456 $schema['mainEntity'] = $this->filter_faq_entities($schema['mainEntity'] ?? []);
457
458 // No usable Q&A pairs — emit a valid WebPage rather than an empty FAQPage.
459 if (empty($schema['mainEntity'])) {
460 return $this->generate_webpage_schema('WebPage', $post);
461 }
462
463 $schema['datePublished'] = get_the_date('c', $post);
464 $schema['dateModified'] = get_the_modified_date('c', $post);
465
466 return $schema;
467 }
468
469 /**
470 * Keep only FAQ entities that genuinely read as a question/answer pair.
471 *
472 * Schema_Builder's content extraction falls back to a heading-followed-by-paragraph
473 * pattern, which on an ordinary page matches every section and would fabricate Q&A
474 * that never appears on the page as such.
475 *
476 * @since 1.32.0
477 * @param array $entities Candidate mainEntity entries
478 * @return array Filtered entries
479 */
480 private function filter_faq_entities(array $entities): array {
481 $filtered = [];
482
483 foreach ($entities as $entity) {
484 $question = isset($entity['name']) ? trim((string) $entity['name']) : '';
485 $answer = isset($entity['acceptedAnswer']['text'])
486 ? trim((string) $entity['acceptedAnswer']['text'])
487 : '';
488
489 if ($question === '' || $answer === '' || strpos($question, '?') === false) {
490 continue;
491 }
492
493 $filtered[] = $entity;
494 }
495
496 return array_values($filtered);
497 }
498
499 /**
500 * Generate WebPage schema markup
501 *
502 * @since 1.0.0
503 * @param string $type WebPage type
504 * @param \WP_Post $post WordPress post object
505 * @return array Schema markup
506 */
507 private function generate_webpage_schema(string $type, \WP_Post $post): array {
508 $schema = [
509 '@context' => self::SCHEMA_CONTEXT,
510 '@type' => $type,
511 'name' => get_the_title($post),
512 'url' => get_permalink($post),
513 'datePublished' => get_the_date('c', $post),
514 'dateModified' => get_the_modified_date('c', $post),
515 ];
516
517 // Add description
518 $excerpt = get_the_excerpt($post);
519 if (!empty($excerpt)) {
520 $schema['description'] = wp_strip_all_tags($excerpt);
521 }
522
523 // Add featured image if available
524 if (has_post_thumbnail($post)) {
525 $image_url = get_the_post_thumbnail_url($post, 'full');
526 if ($image_url) {
527 $schema['image'] = $image_url;
528 }
529 }
530
531 return $schema;
532 }
533
534 /**
535 * Generate ImageObject schema markup
536 *
537 * @since 1.0.0
538 * @param \WP_Post $post WordPress post object (attachment)
539 * @return array Schema markup
540 */
541 private function generate_image_schema(\WP_Post $post): array {
542 $image_url = wp_get_attachment_url($post->ID);
543 $image_meta = wp_get_attachment_metadata($post->ID);
544
545 $schema = [
546 '@context' => self::SCHEMA_CONTEXT,
547 '@type' => 'ImageObject',
548 'contentUrl' => $image_url,
549 'url' => get_permalink($post),
550 'name' => get_the_title($post),
551 ];
552
553 // Add caption/description
554 $caption = wp_get_attachment_caption($post->ID);
555 if (!empty($caption)) {
556 $schema['caption'] = $caption;
557 $schema['description'] = $caption;
558 }
559
560 // Add dimensions
561 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
562 $schema['width'] = $image_meta['width'];
563 $schema['height'] = $image_meta['height'];
564 }
565
566 // Add upload date
567 $schema['uploadDate'] = get_the_date('c', $post);
568
569 return $schema;
570 }
571
572 /**
573 * Generate VideoObject schema markup
574 *
575 * @since 1.0.0
576 * @param \WP_Post $post WordPress post object (attachment or post with video)
577 * @return array Schema markup
578 */
579 private function generate_video_schema(\WP_Post $post): array {
580 $schema = [
581 '@context' => self::SCHEMA_CONTEXT,
582 '@type' => 'VideoObject',
583 'name' => get_the_title($post),
584 'url' => get_permalink($post),
585 ];
586
587 // Add description
588 $description = get_the_excerpt($post);
589 if (empty($description)) {
590 $caption = wp_get_attachment_caption($post->ID);
591 if (!empty($caption)) {
592 $description = $caption;
593 }
594 }
595 if (!empty($description)) {
596 $schema['description'] = wp_strip_all_tags($description);
597 }
598
599 // For video attachments, add contentUrl
600 if ($post->post_type === 'attachment') {
601 $video_url = wp_get_attachment_url($post->ID);
602 if ($video_url) {
603 $schema['contentUrl'] = $video_url;
604 }
605
606 // Add upload date
607 $schema['uploadDate'] = get_the_date('c', $post);
608 }
609
610 // Add thumbnail/poster image if available
611 if (has_post_thumbnail($post)) {
612 $thumbnail_url = get_the_post_thumbnail_url($post, 'full');
613 if ($thumbnail_url) {
614 $schema['thumbnailUrl'] = $thumbnail_url;
615 }
616 }
617
618 // Add duration if available from meta
619 $duration = get_post_meta($post->ID, '_thinkrank_video_duration', true);
620 if (!empty($duration)) {
621 $schema['duration'] = $duration; // Should be in ISO 8601 format (e.g., PT1M30S)
622 }
623
624 // Add embed URL if available from meta
625 $embed_url = get_post_meta($post->ID, '_thinkrank_video_embed_url', true);
626 if (!empty($embed_url)) {
627 $schema['embedUrl'] = $embed_url;
628 }
629
630 return $schema;
631 }
632
633 /**
634 * Generate Product schema markup
635 *
636 * Generates valid Schema.org Product markup with required and recommended properties.
637 * Supports custom meta fields and WooCommerce integration.
638 *
639 * @since 1.0.0
640 * @param \WP_Post $post WordPress post object
641 * @return array Schema markup
642 */
643 private function generate_product_schema(\WP_Post $post): array {
644 // Base Product schema with required properties
645 $schema = [
646 '@context' => self::SCHEMA_CONTEXT,
647 '@type' => 'Product',
648 'name' => get_the_title($post),
649 'url' => get_permalink($post),
650 ];
651
652 // Add description (required for valid Product schema)
653 $description = $this->get_product_description($post);
654 if (!empty($description)) {
655 $schema['description'] = $description;
656 }
657
658 // Add image (required for valid Product schema)
659 $image = $this->get_product_image($post);
660 if (!empty($image)) {
661 $schema['image'] = $image;
662 }
663
664 // Add SKU if available
665 $sku = $this->get_product_sku($post);
666 if (!empty($sku)) {
667 $schema['sku'] = $sku;
668 }
669
670 // Add brand (recommended)
671 $brand = $this->get_product_brand($post);
672 if (!empty($brand)) {
673 $schema['brand'] = [
674 '@type' => 'Brand',
675 'name' => $brand,
676 ];
677 }
678
679 // Add offers (required for valid Product schema)
680 $offers = $this->get_product_offers($post);
681 if (!empty($offers)) {
682 $schema['offers'] = $offers;
683 }
684
685 // Add aggregate rating (recommended)
686 $rating = $this->get_product_rating($post);
687 if (!empty($rating)) {
688 $schema['aggregateRating'] = $rating;
689 }
690
691 // Add reviews (recommended)
692 $reviews = $this->get_product_reviews($post);
693 if (!empty($reviews)) {
694 $schema['review'] = $reviews;
695 }
696
697 return $schema;
698 }
699
700 /**
701 * Generate Event schema markup (placeholder)
702 *
703 * @since 1.0.0
704 * @param \WP_Post $post WordPress post object
705 * @return array Schema markup
706 */
707 private function generate_event_schema(\WP_Post $post): array {
708 // Basic Event schema - can be extended based on requirements
709 return $this->generate_webpage_schema('WebPage', $post);
710 }
711
712 /**
713 * Get publisher schema (Organization or Person)
714 *
715 * @since 1.0.0
716 * @return array Publisher schema
717 */
718 private function get_publisher_schema(): array {
719 $site_name = get_bloginfo('name');
720 $site_url = home_url();
721
722 $publisher = [
723 '@type' => 'Organization',
724 'name' => $site_name,
725 'url' => $site_url,
726 ];
727
728 // Add logo if available
729 $custom_logo_id = get_theme_mod('custom_logo');
730 if ($custom_logo_id) {
731 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
732 if ($logo_url) {
733 $publisher['logo'] = [
734 '@type' => 'ImageObject',
735 'url' => $logo_url,
736 ];
737 }
738 }
739
740 return $publisher;
741 }
742
743 /**
744 * Get product description
745 *
746 * @since 1.0.0
747 * @param \WP_Post $post WordPress post object
748 * @return string Product description
749 */
750 private function get_product_description(\WP_Post $post): string {
751 // Try custom meta field first
752 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
753
754 // Fallback to excerpt or content
755 if (empty($description)) {
756 $description = get_the_excerpt($post);
757 }
758
759 if (empty($description)) {
760 $description = wp_trim_words(wp_strip_all_tags($post->post_content), 30);
761 }
762
763 return wp_strip_all_tags($description);
764 }
765
766 /**
767 * Get product image
768 *
769 * @since 1.0.0
770 * @param \WP_Post $post WordPress post object
771 * @return array|string Product image data
772 */
773 private function get_product_image(\WP_Post $post) {
774 // Try featured image first
775 if (has_post_thumbnail($post)) {
776 $image_id = get_post_thumbnail_id($post);
777 $image_url = wp_get_attachment_image_url($image_id, 'full');
778
779 if ($image_url) {
780 $image_meta = wp_get_attachment_metadata($image_id);
781
782 return [
783 '@type' => 'ImageObject',
784 'url' => $image_url,
785 // SVGs report 0x0 — send null rather than a zero dimension.
786 'width' => !empty($image_meta['width']) ? (int) $image_meta['width'] : null,
787 'height' => !empty($image_meta['height']) ? (int) $image_meta['height'] : null,
788 ];
789 }
790 }
791
792 // Try custom meta field
793 $custom_image = get_post_meta($post->ID, '_thinkrank_product_image', true);
794 if (!empty($custom_image)) {
795 return $custom_image;
796 }
797
798 return '';
799 }
800
801 /**
802 * Get product SKU
803 *
804 * @since 1.0.0
805 * @param \WP_Post $post WordPress post object
806 * @return string Product SKU
807 */
808 private function get_product_sku(\WP_Post $post): string {
809 // Try custom meta field
810 $sku = get_post_meta($post->ID, '_thinkrank_product_sku', true);
811
812 // Try WooCommerce if available
813 if (empty($sku) && function_exists('wc_get_product')) {
814 $product = wc_get_product($post->ID);
815 if ($product) {
816 $sku = $product->get_sku();
817 }
818 }
819
820 return (string) $sku;
821 }
822
823 /**
824 * Get product brand
825 *
826 * @since 1.0.0
827 * @param \WP_Post $post WordPress post object
828 * @return string Product brand
829 */
830 private function get_product_brand(\WP_Post $post): string {
831 // Try custom meta field
832 $brand = get_post_meta($post->ID, '_thinkrank_product_brand', true);
833
834 // Try WooCommerce brand taxonomy if available
835 if (empty($brand) && taxonomy_exists('product_brand')) {
836 $terms = get_the_terms($post->ID, 'product_brand');
837 if (!empty($terms) && !is_wp_error($terms)) {
838 $brand = $terms[0]->name;
839 }
840 }
841
842 return (string) $brand;
843 }
844
845 /**
846 * Get product offers
847 *
848 * @since 1.0.0
849 * @param \WP_Post $post WordPress post object
850 * @return array Product offers data
851 */
852 private function get_product_offers(\WP_Post $post): array {
853 $offers = [
854 '@type' => 'Offer',
855 'url' => get_permalink($post),
856 ];
857
858 // Get price
859 $price = get_post_meta($post->ID, '_thinkrank_product_price', true);
860
861 // Try WooCommerce if available
862 if (empty($price) && function_exists('wc_get_product')) {
863 $product = wc_get_product($post->ID);
864 if ($product) {
865 $price = $product->get_price();
866 }
867 }
868
869 if (!empty($price)) {
870 $offers['price'] = (string) $price;
871 }
872
873 // Get currency
874 $currency = get_post_meta($post->ID, '_thinkrank_product_currency', true);
875
876 // Try WooCommerce currency if available
877 if (empty($currency) && function_exists('get_woocommerce_currency')) {
878 $currency = get_woocommerce_currency();
879 }
880
881 // Default to USD
882 if (empty($currency)) {
883 $currency = 'USD';
884 }
885
886 $offers['priceCurrency'] = $currency;
887
888 // Get availability
889 $availability = get_post_meta($post->ID, '_thinkrank_product_availability', true);
890
891 // Try WooCommerce if available
892 if (empty($availability) && function_exists('wc_get_product')) {
893 $product = wc_get_product($post->ID);
894 if ($product) {
895 $availability = $product->is_in_stock() ? 'InStock' : 'OutOfStock';
896 }
897 }
898
899 // Default to InStock
900 if (empty($availability)) {
901 $availability = 'InStock';
902 }
903
904 // Ensure proper schema.org URL format
905 if (strpos($availability, 'https://schema.org/') !== 0) {
906 $offers['availability'] = 'https://schema.org/' . $availability;
907 } else {
908 $offers['availability'] = $availability;
909 }
910
911 // Add price valid until if available
912 $price_valid_until = get_post_meta($post->ID, '_thinkrank_product_price_valid_until', true);
913 if (!empty($price_valid_until)) {
914 $offers['priceValidUntil'] = $price_valid_until;
915 }
916
917 return $offers;
918 }
919
920 /**
921 * Get product aggregate rating
922 *
923 * @since 1.0.0
924 * @param \WP_Post $post WordPress post object
925 * @return array Product rating data
926 */
927 private function get_product_rating(\WP_Post $post): array {
928 $rating = [];
929
930 // Try custom meta fields
931 $rating_value = get_post_meta($post->ID, '_thinkrank_product_rating_value', true);
932 $rating_count = get_post_meta($post->ID, '_thinkrank_product_rating_count', true);
933
934 // Try WooCommerce if available
935 if ((empty($rating_value) || empty($rating_count)) && function_exists('wc_get_product')) {
936 $product = wc_get_product($post->ID);
937 if ($product) {
938 $wc_rating_count = $product->get_rating_count();
939 $wc_average = $product->get_average_rating();
940
941 if ($wc_rating_count > 0 && $wc_average > 0) {
942 $rating_value = $wc_average;
943 $rating_count = $wc_rating_count;
944 }
945 }
946 }
947
948 // Only return rating if we have both value and count
949 if (!empty($rating_value) && !empty($rating_count)) {
950 $rating = [
951 '@type' => 'AggregateRating',
952 'ratingValue' => (string) $rating_value,
953 'reviewCount' => (int) $rating_count,
954 'bestRating' => '5',
955 ];
956 }
957
958 return $rating;
959 }
960
961 /**
962 * Get product reviews
963 *
964 * @since 1.0.0
965 * @param \WP_Post $post WordPress post object
966 * @return array Product reviews data
967 */
968 private function get_product_reviews(\WP_Post $post): array {
969 $reviews = [];
970
971 // Try WooCommerce reviews if available
972 if (function_exists('wc_get_product')) {
973 $product = wc_get_product($post->ID);
974 if ($product) {
975 $comments = get_comments([
976 'post_id' => $post->ID,
977 'status' => 'approve',
978 'type' => 'review',
979 'number' => 5, // Limit to 5 most recent reviews
980 ]);
981
982 foreach ($comments as $comment) {
983 $rating = get_comment_meta($comment->comment_ID, 'rating', true);
984
985 if (!empty($rating)) {
986 $reviews[] = [
987 '@type' => 'Review',
988 'reviewRating' => [
989 '@type' => 'Rating',
990 'ratingValue' => (string) $rating,
991 'bestRating' => '5',
992 ],
993 'author' => [
994 '@type' => 'Person',
995 'name' => $comment->comment_author,
996 ],
997 'reviewBody' => wp_strip_all_tags($comment->comment_content),
998 'datePublished' => get_comment_date('c', $comment),
999 ];
1000 }
1001 }
1002 }
1003 }
1004
1005 // Try custom meta field for manual reviews
1006 if (empty($reviews)) {
1007 $custom_reviews = get_post_meta($post->ID, '_thinkrank_product_reviews', true);
1008 if (!empty($custom_reviews) && is_array($custom_reviews)) {
1009 $reviews = $custom_reviews;
1010 }
1011 }
1012
1013 return $reviews;
1014 }
1015
1016 /**
1017 * Register generated schema with the request's schema graph.
1018 *
1019 * Replaces the direct echo this class used to do: the graph arbitrates
1020 * between this post-type-wide schema and the Schema Manager's per-post
1021 * deployment, then emits one linked @graph (#355).
1022 *
1023 * @since 1.32.0
1024 * @param array $schema Schema markup array
1025 * @param string $schema_type Schema @type
1026 * @param string $source Producer key used for precedence
1027 * @return void
1028 */
1029 private function register_schema(array $schema, string $schema_type, string $source): void {
1030 if (empty($schema)) {
1031 return;
1032 }
1033
1034 if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
1035 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-schema-graph.php';
1036 }
1037
1038 Schema_Graph::instance()->add_primary($schema, $schema_type, $source);
1039 }
1040 }
1041