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

1,166 lines 40.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 // One Product entity per product page: when ThinkRank emits the
59 // Product schema (the default for WooCommerce products), WooCommerce
60 // core's own JSON-LD must stand down, or the page carries two
61 // aggregateRating blocks and Search Console raises the critical
62 // "Review has multiple aggregate ratings" error. Registered eagerly
63 // and decided lazily inside the callback, because WooCommerce
64 // generates its data during the product template render — which on
65 // block themes can run before wp_head, too early for a flag set at
66 // output time to exist yet.
67 add_filter('woocommerce_structured_data_product', [$this, 'suppress_woocommerce_product_schema'], 20, 2);
68 }
69
70 /**
71 * Yield WooCommerce's Product structured data when ThinkRank emits the
72 * Product entity for the page being viewed.
73 *
74 * Mirrors what other SEO plugins do with WC_Structured_Data: exactly one
75 * plugin may describe the product. Suppression is surgical — only the
76 * queried product on its own singular view, only when this class's
77 * settings resolution says a Product schema will be generated (explicit
78 * or the WooCommerce default), and WooCommerce's breadcrumb and other
79 * structured data are never touched. With ThinkRank's product schema
80 * disabled or set to another type, WooCommerce's markup passes through
81 * unchanged.
82 *
83 * @since 2.0.1
84 * @param array $markup WooCommerce's generated Product markup.
85 * @param mixed $product WC_Product being described.
86 * @return array Original markup, or empty to suppress.
87 */
88 public function suppress_woocommerce_product_schema($markup, $product = null) {
89 if (!is_array($markup) || !is_singular()) {
90 return $markup;
91 }
92
93 // Only the main product of this page — a card grid or related-products
94 // widget describing other products is not ours to silence.
95 $queried_id = (int) get_queried_object_id();
96 $product_id = is_object($product) && method_exists($product, 'get_id') ? (int) $product->get_id() : 0;
97 if (!$queried_id || !$product_id || $queried_id !== $product_id) {
98 return $markup;
99 }
100
101 $post_type = (string) get_post_type($queried_id);
102 if ($post_type === '') {
103 return $markup;
104 }
105
106 $settings = $this->get_global_seo_settings($post_type);
107 if (($settings['schema_type'] ?? '') === 'Product') {
108 return [];
109 }
110
111 // A per-post DEPLOYED Product schema duplicates WooCommerce's markup
112 // just the same, even when the post-type-wide setting points elsewhere.
113 // Checked second because the default path above answers without a
114 // query; this one is a single indexed lookup and only runs on the
115 // rare configured-away sites.
116 if ($this->post_has_deployed_product_schema($queried_id)) {
117 return [];
118 }
119
120 return $markup;
121 }
122
123 /**
124 * Whether an active per-post Product schema deployment exists for a post.
125 *
126 * Reads the deployment table directly rather than constructing
127 * Schema_Management_System — this runs inside WooCommerce's structured
128 * data filter on product pages, where spinning up the full manager (and
129 * its builder) to answer a yes/no question would be waste. Query shape
130 * matches get_deployed_schemas(): active rows for the post context.
131 *
132 * @since 2.0.1
133 * @param int $post_id Post to check.
134 * @return bool
135 */
136 private function post_has_deployed_product_schema(int $post_id): bool {
137 global $wpdb;
138
139 $table = $wpdb->prefix . 'thinkrank_seo_schema';
140
141 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one indexed EXISTS-style lookup on the render path; the deployment cache layer belongs to the full manager this deliberately avoids constructing.
142 $found = $wpdb->get_var($wpdb->prepare(
143 "SELECT 1 FROM {$table} WHERE context_type = 'post' AND context_id = %d AND schema_type = 'Product' AND is_active = 1 LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
144 $post_id
145 ));
146
147 return '1' === (string) $found;
148 }
149
150 /**
151 * Output JSON-LD schema markup based on Global SEO settings
152 *
153 * @since 1.0.0
154 * @return void
155 */
156 public function output_global_seo_schema(): void {
157 // Archives get a CollectionPage schema instead of the per-post-type one
158 if (!is_singular()) {
159 $this->output_archive_schema();
160 return;
161 }
162
163 $post = get_post();
164 if (!$post) {
165 return;
166 }
167
168 $post_type = get_post_type($post);
169 if (!$post_type) {
170 return;
171 }
172
173 // Get Global SEO settings for this post type
174 $settings = $this->get_global_seo_settings($post_type);
175 if (empty($settings) || empty($settings['schema_type'])) {
176 return;
177 }
178
179 $schema_type = $settings['schema_type'];
180 $article_type = $settings['article_type'] ?? '';
181 $media_type = $settings['media_type'] ?? '';
182
183 // Generate schema markup
184 $schema = $this->generate_schema($schema_type, $article_type, $media_type, $post);
185
186 if (empty($schema)) {
187 return;
188 }
189
190 /**
191 * Filter the generated schema graph before output.
192 *
193 * Lets add-ons (e.g. ThinkRank Pro's WooCommerce module) enrich the
194 * schema — adding GTIN/MPN, variation offers, brand, etc. — without
195 * forking this class.
196 *
197 * @since 1.14.0
198 *
199 * @param array $schema The schema array.
200 * @param string $schema_type The configured schema type.
201 * @param \WP_Post $post The current post.
202 */
203 $schema = apply_filters('thinkrank_schema_output', $schema, $schema_type, $post);
204
205 if (empty($schema)) {
206 return;
207 }
208
209 // Register as a candidate for the page's single page-level entity. The
210 // Schema Manager's per-post deployment outranks this post-type-wide
211 // default when both describe the same page (#355).
212 $this->register_schema($schema, $schema_type, 'global_seo');
213 }
214
215 /**
216 * Output CollectionPage schema for archive contexts.
217 *
218 * Covers the blog home, post type archives (e.g. a docs archive) and
219 * taxonomy archives. Search results, 404s and other contexts get nothing.
220 *
221 * @since 1.16.0
222 * @return void
223 */
224 private function output_archive_schema(): void {
225 $name = '';
226 $url = '';
227 $description = '';
228
229 if (is_home() && !is_front_page()) {
230 $posts_page_id = (int) get_option('page_for_posts');
231 $name = $posts_page_id ? get_the_title($posts_page_id) : __('Blog', 'thinkrank');
232 $url = $posts_page_id ? (string) get_permalink($posts_page_id) : home_url('/');
233 } elseif (is_post_type_archive()) {
234 $post_type_object = get_queried_object();
235
236 // WooCommerce maps the shop archive onto a real page, so
237 // get_queried_object() returns that WP_Post while
238 // is_post_type_archive() is still true. Bailing here left every
239 // store's main archive with no CollectionPage (#466). Fall back to
240 // the query var, exactly as the canonical resolver already does.
241 if (!$post_type_object instanceof \WP_Post_Type) {
242 $queried_post_type = (string) get_query_var('post_type');
243 $post_type_object = $queried_post_type
244 ? get_post_type_object($queried_post_type)
245 : null;
246 }
247
248 if (!$post_type_object instanceof \WP_Post_Type) {
249 return;
250 }
251 $name = $post_type_object->labels->name ?? $post_type_object->label;
252 $url = (string) get_post_type_archive_link($post_type_object->name);
253 $description = $post_type_object->description;
254 } elseif (is_category() || is_tag() || is_tax()) {
255 $term = get_queried_object();
256 if (!$term instanceof \WP_Term) {
257 return;
258 }
259 $term_link = get_term_link($term);
260 if (is_wp_error($term_link)) {
261 return;
262 }
263 $name = $term->name;
264 $url = $term_link;
265 $description = (string) term_description($term);
266 } else {
267 return;
268 }
269
270 if (empty($url)) {
271 return;
272 }
273
274 // Page 2 of an archive is a different URL and must be a different node.
275 // The link above is always the un-paginated one, so Schema_Graph::base_url()
276 // minted the identical #collectionpage and #breadcrumb @id on every
277 // page — distinct URLs claiming the same node identity (#397).
278 $url = \ThinkRank\Frontend\SEO_Manager::with_pagination(
279 (string) $url,
280 \ThinkRank\Frontend\SEO_Manager::current_page_number()
281 );
282
283 $schema = [
284 '@context' => self::SCHEMA_CONTEXT,
285 '@type' => 'CollectionPage',
286 'name' => $name,
287 'url' => $url,
288 'isPartOf' => [
289 '@type' => 'WebSite',
290 '@id' => home_url('/#website'),
291 'url' => home_url('/'),
292 ],
293 ];
294
295 $description = trim(wp_strip_all_tags($description));
296 if (!empty($description)) {
297 $schema['description'] = $description;
298 }
299
300 /**
301 * Filter the archive CollectionPage schema before output.
302 *
303 * @since 1.16.0
304 *
305 * @param array $schema The schema array ([] suppresses output).
306 */
307 $schema = apply_filters('thinkrank_archive_schema_output', $schema);
308
309 if (empty($schema)) {
310 return;
311 }
312
313 $this->register_schema($schema, 'CollectionPage', 'global_seo');
314 }
315
316 /**
317 * Whether ThinkRank would emit structured data for a given post type.
318 *
319 * Reflects the exact decision `output_global_seo_schema()` makes for
320 * singular views: schema is emitted when a `schema_type` resolves for the
321 * post type — either an explicit saved value or the built-in per-post-type
322 * default. Exposed so the Site SEO Analyzer can ask the output layer
323 * directly instead of re-reading a legacy option, keeping the audit and the
324 * rendered page from ever disagreeing about whether schema is configured.
325 *
326 * @since 1.23.1
327 * @param string $post_type Post type slug.
328 * @return bool True when structured data would be output for this post type.
329 */
330 public function would_output_schema(string $post_type): bool {
331 $settings = $this->get_global_seo_settings($post_type);
332
333 return !empty($settings['schema_type']);
334 }
335
336 /**
337 * Get Global SEO settings for a specific post type
338 *
339 * @since 1.0.0
340 * @param string $post_type Post type
341 * @return array Settings array
342 */
343 private function get_global_seo_settings(string $post_type): array {
344 $all_settings = get_option(self::OPTION_NAME, []);
345 $settings = $all_settings[$post_type] ?? [];
346
347 // Fall back to a sensible default schema type when nothing is saved for
348 // this post type, so structured data works out of the box on sites that
349 // never opened the Global SEO settings (e.g. migrated from Rank Math).
350 // An explicit saved schema_type always wins. Mirrors the per-post-type
351 // defaults the REST endpoint (Global_SEO_Endpoint::get_default_settings)
352 // exposes to the admin UI.
353 if (empty($settings['schema_type'])) {
354 $default = $this->get_default_schema_type($post_type);
355 if ($default !== null) {
356 $settings = array_merge($default, $settings);
357 }
358 }
359
360 return $settings;
361 }
362
363 /**
364 * Default schema type (and sub-type) for a post type when unconfigured.
365 *
366 * @since 1.15.x
367 * @param string $post_type Post type slug
368 * @return array|null ['schema_type' => ..., 'article_type' => ..., 'media_type' => ...] or null to emit nothing
369 */
370 private function get_default_schema_type(string $post_type): ?array {
371 switch ($post_type) {
372 case 'post':
373 return ['schema_type' => 'Article', 'article_type' => 'BlogPosting', 'media_type' => ''];
374 case 'page':
375 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
376 case 'attachment':
377 return ['schema_type' => 'Media', 'article_type' => '', 'media_type' => 'ImageObject'];
378 case 'product':
379 // Only claim Product schema when WooCommerce is actually present,
380 // so a generic CPT named "product" without WooCommerce still gets
381 // WebPage rather than an offers-less Product graph.
382 return class_exists('WooCommerce')
383 ? ['schema_type' => 'Product', 'article_type' => '', 'media_type' => '']
384 : ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
385 default:
386 // Public custom post types (e.g. BetterDocs `docs`) get WebPage.
387 $object = get_post_type_object($post_type);
388 if ($object && empty($object->public)) {
389 return null;
390 }
391 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
392 }
393 }
394
395 /**
396 * Generate schema markup based on schema type
397 *
398 * @since 1.0.0
399 * @param string $schema_type Schema type (e.g., 'Article', 'WebPage', 'Media')
400 * @param string $article_type Article type (e.g., 'BlogPosting', 'NewsArticle')
401 * @param string $media_type Media type (e.g., 'ImageObject', 'VideoObject')
402 * @param \WP_Post $post WordPress post object
403 * @return array Schema markup array
404 */
405 private function generate_schema(string $schema_type, string $article_type, string $media_type, \WP_Post $post): array {
406 // Determine the actual type to use based on schema_type and sub-types
407 $type = $schema_type;
408
409 // Use article_type if schema_type is 'Article' and article_type is specified
410 if ($schema_type === 'Article' && !empty($article_type)) {
411 $type = $article_type;
412 }
413
414 // Use media_type if schema_type is 'Media' and media_type is specified
415 if ($schema_type === 'Media' && !empty($media_type)) {
416 $type = $media_type;
417 }
418
419 // Generate schema based on type
420 switch ($type) {
421 case 'Article':
422 case 'BlogPosting':
423 case 'NewsArticle':
424 case 'ScholarlyArticle':
425 case 'TechArticle':
426 return $this->generate_article_schema($type, $post);
427
428 case 'FAQPage':
429 return $this->generate_faq_schema($post);
430
431 case 'WebPage':
432 case 'AboutPage':
433 case 'ContactPage':
434 case 'ProfilePage':
435 return $this->generate_webpage_schema($type, $post);
436
437 case 'ImageObject':
438 return $this->generate_image_schema($post);
439
440 case 'VideoObject':
441 return $this->generate_video_schema($post);
442
443 case 'Product':
444 return $this->generate_product_schema($post);
445
446 case 'Event':
447 return $this->generate_event_schema($post);
448
449 case 'Media':
450 // Fallback to ImageObject if Media is selected but no media_type specified
451 return $this->generate_image_schema($post);
452
453 default:
454 // Fallback to WebPage for unknown types
455 return $this->generate_webpage_schema('WebPage', $post);
456 }
457 }
458
459 /**
460 * Generate Article schema markup
461 *
462 * @since 1.0.0
463 * @param string $type Article type
464 * @param \WP_Post $post WordPress post object
465 * @return array Schema markup
466 */
467 private function generate_article_schema(string $type, \WP_Post $post): array {
468 $schema = [
469 '@context' => self::SCHEMA_CONTEXT,
470 '@type' => $type,
471 'headline' => get_the_title($post),
472 'url' => get_permalink($post),
473 'datePublished' => get_the_date('c', $post),
474 'dateModified' => get_the_modified_date('c', $post),
475 ];
476
477 // Add description
478 $excerpt = get_the_excerpt($post);
479 if (!empty($excerpt)) {
480 $schema['description'] = wp_strip_all_tags($excerpt);
481 }
482
483 // Add author
484 $author_id = $post->post_author;
485 if ($author_id) {
486 $schema['author'] = [
487 '@type' => 'Person',
488 'name' => get_the_author_meta('display_name', $author_id),
489 'url' => get_author_posts_url($author_id),
490 ];
491 }
492
493 // Add publisher (site info)
494 $schema['publisher'] = $this->get_publisher_schema();
495
496 // Add featured image if available
497 if (has_post_thumbnail($post)) {
498 $image_id = get_post_thumbnail_id($post);
499 $image_url = wp_get_attachment_image_url($image_id, 'full');
500 if ($image_url) {
501 $schema['image'] = [
502 '@type' => 'ImageObject',
503 'url' => $image_url,
504 ];
505
506 // Add image dimensions if available
507 $image_meta = wp_get_attachment_metadata($image_id);
508 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
509 $schema['image']['width'] = $image_meta['width'];
510 $schema['image']['height'] = $image_meta['height'];
511 }
512 }
513 }
514
515 // Add main entity of page
516 $schema['mainEntityOfPage'] = [
517 '@type' => 'WebPage',
518 '@id' => get_permalink($post),
519 ];
520
521 return $schema;
522 }
523
524 /**
525 * Generate FAQPage schema markup
526 *
527 * FAQPage previously fell through to generate_webpage_schema(), which emits a
528 * WebPage-shaped object labelled @type FAQPage with no mainEntity — invalid for
529 * rich results. Delegate to Schema_Builder instead, which owns the FAQ question
530 * extraction already used by the deploy path, rather than growing a second
531 * FAQ implementation here.
532 *
533 * Unlike the deploy path, this runs automatically on every post of the type with
534 * no human reviewing the result, so questions that don't actually read as
535 * questions are dropped and a page with none left falls back to WebPage — an
536 * FAQPage with an empty mainEntity is worse than a valid WebPage.
537 *
538 * @since 1.32.0
539 * @param \WP_Post $post WordPress post object
540 * @return array Schema markup
541 */
542 private function generate_faq_schema(\WP_Post $post): array {
543 if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) {
544 $builder_file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php';
545 if (!file_exists($builder_file)) {
546 return $this->generate_webpage_schema('WebPage', $post);
547 }
548 require_once $builder_file;
549 }
550
551 $excerpt = get_the_excerpt($post);
552
553 $builder = new \ThinkRank\SEO\Schema_Builder();
554 $schema = $builder->build_schema(
555 'FAQPage',
556 [
557 'title' => get_the_title($post),
558 'content' => $post->post_content,
559 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '',
560 'url' => get_permalink($post),
561 ],
562 get_post_type($post) === 'page' ? 'page' : 'post'
563 );
564
565 if (!empty($schema['_error'])) {
566 return $this->generate_webpage_schema('WebPage', $post);
567 }
568
569 $schema['mainEntity'] = $this->filter_faq_entities($schema['mainEntity'] ?? []);
570
571 // No usable Q&A pairs — emit a valid WebPage rather than an empty FAQPage.
572 if (empty($schema['mainEntity'])) {
573 return $this->generate_webpage_schema('WebPage', $post);
574 }
575
576 $schema['datePublished'] = get_the_date('c', $post);
577 $schema['dateModified'] = get_the_modified_date('c', $post);
578
579 return $schema;
580 }
581
582 /**
583 * Keep only FAQ entities that genuinely read as a question/answer pair.
584 *
585 * Schema_Builder's content extraction falls back to a heading-followed-by-paragraph
586 * pattern, which on an ordinary page matches every section and would fabricate Q&A
587 * that never appears on the page as such.
588 *
589 * @since 1.32.0
590 * @param array $entities Candidate mainEntity entries
591 * @return array Filtered entries
592 */
593 private function filter_faq_entities(array $entities): array {
594 $filtered = [];
595
596 foreach ($entities as $entity) {
597 $question = isset($entity['name']) ? trim((string) $entity['name']) : '';
598 $answer = isset($entity['acceptedAnswer']['text'])
599 ? trim((string) $entity['acceptedAnswer']['text'])
600 : '';
601
602 if ($question === '' || $answer === '' || strpos($question, '?') === false) {
603 continue;
604 }
605
606 $filtered[] = $entity;
607 }
608
609 return array_values($filtered);
610 }
611
612 /**
613 * Generate WebPage schema markup
614 *
615 * @since 1.0.0
616 * @param string $type WebPage type
617 * @param \WP_Post $post WordPress post object
618 * @return array Schema markup
619 */
620 private function generate_webpage_schema(string $type, \WP_Post $post): array {
621 $schema = [
622 '@context' => self::SCHEMA_CONTEXT,
623 '@type' => $type,
624 'name' => get_the_title($post),
625 'url' => get_permalink($post),
626 'datePublished' => get_the_date('c', $post),
627 'dateModified' => get_the_modified_date('c', $post),
628 ];
629
630 // Add description
631 $excerpt = get_the_excerpt($post);
632 if (!empty($excerpt)) {
633 $schema['description'] = wp_strip_all_tags($excerpt);
634 }
635
636 // Add featured image if available
637 if (has_post_thumbnail($post)) {
638 $image_url = get_the_post_thumbnail_url($post, 'full');
639 if ($image_url) {
640 $schema['image'] = $image_url;
641 }
642 }
643
644 return $schema;
645 }
646
647 /**
648 * Generate ImageObject schema markup
649 *
650 * @since 1.0.0
651 * @param \WP_Post $post WordPress post object (attachment)
652 * @return array Schema markup
653 */
654 private function generate_image_schema(\WP_Post $post): array {
655 $image_url = wp_get_attachment_url($post->ID);
656 $image_meta = wp_get_attachment_metadata($post->ID);
657
658 $schema = [
659 '@context' => self::SCHEMA_CONTEXT,
660 '@type' => 'ImageObject',
661 'contentUrl' => $image_url,
662 'url' => get_permalink($post),
663 'name' => get_the_title($post),
664 ];
665
666 // Add caption/description
667 $caption = wp_get_attachment_caption($post->ID);
668 if (!empty($caption)) {
669 $schema['caption'] = $caption;
670 $schema['description'] = $caption;
671 }
672
673 // Add dimensions
674 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
675 $schema['width'] = $image_meta['width'];
676 $schema['height'] = $image_meta['height'];
677 }
678
679 // Add upload date
680 $schema['uploadDate'] = get_the_date('c', $post);
681
682 return $schema;
683 }
684
685 /**
686 * Generate VideoObject schema markup
687 *
688 * @since 1.0.0
689 * @param \WP_Post $post WordPress post object (attachment or post with video)
690 * @return array Schema markup
691 */
692 private function generate_video_schema(\WP_Post $post): array {
693 $schema = [
694 '@context' => self::SCHEMA_CONTEXT,
695 '@type' => 'VideoObject',
696 'name' => get_the_title($post),
697 'url' => get_permalink($post),
698 ];
699
700 // Add description
701 $description = get_the_excerpt($post);
702 if (empty($description)) {
703 $caption = wp_get_attachment_caption($post->ID);
704 if (!empty($caption)) {
705 $description = $caption;
706 }
707 }
708 if (!empty($description)) {
709 $schema['description'] = wp_strip_all_tags($description);
710 }
711
712 // For video attachments, add contentUrl
713 if ($post->post_type === 'attachment') {
714 $video_url = wp_get_attachment_url($post->ID);
715 if ($video_url) {
716 $schema['contentUrl'] = $video_url;
717 }
718
719 // Add upload date
720 $schema['uploadDate'] = get_the_date('c', $post);
721 }
722
723 // Add thumbnail/poster image if available
724 if (has_post_thumbnail($post)) {
725 $thumbnail_url = get_the_post_thumbnail_url($post, 'full');
726 if ($thumbnail_url) {
727 $schema['thumbnailUrl'] = $thumbnail_url;
728 }
729 }
730
731 // Add duration if available from meta
732 $duration = get_post_meta($post->ID, '_thinkrank_video_duration', true);
733 if (!empty($duration)) {
734 $schema['duration'] = $duration; // Should be in ISO 8601 format (e.g., PT1M30S)
735 }
736
737 // Add embed URL if available from meta
738 $embed_url = get_post_meta($post->ID, '_thinkrank_video_embed_url', true);
739 if (!empty($embed_url)) {
740 $schema['embedUrl'] = $embed_url;
741 }
742
743 return $schema;
744 }
745
746 /**
747 * Generate Product schema markup
748 *
749 * Generates valid Schema.org Product markup with required and recommended properties.
750 * Supports custom meta fields and WooCommerce integration.
751 *
752 * @since 1.0.0
753 * @param \WP_Post $post WordPress post object
754 * @return array Schema markup
755 */
756 private function generate_product_schema(\WP_Post $post): array {
757 // Base Product schema with required properties
758 $schema = [
759 '@context' => self::SCHEMA_CONTEXT,
760 '@type' => 'Product',
761 'name' => get_the_title($post),
762 'url' => get_permalink($post),
763 ];
764
765 // Add description (required for valid Product schema)
766 $description = $this->get_product_description($post);
767 if (!empty($description)) {
768 $schema['description'] = $description;
769 }
770
771 // Add image (required for valid Product schema)
772 $image = $this->get_product_image($post);
773 if (!empty($image)) {
774 $schema['image'] = $image;
775 }
776
777 // Add SKU if available
778 $sku = $this->get_product_sku($post);
779 if (!empty($sku)) {
780 $schema['sku'] = $sku;
781 }
782
783 // Add brand (recommended)
784 $brand = $this->get_product_brand($post);
785 if (!empty($brand)) {
786 $schema['brand'] = [
787 '@type' => 'Brand',
788 'name' => $brand,
789 ];
790 }
791
792 // Add offers (required for valid Product schema)
793 $offers = $this->get_product_offers($post);
794 if (!empty($offers)) {
795 $schema['offers'] = $offers;
796 }
797
798 // Add aggregate rating (recommended)
799 $rating = $this->get_product_rating($post);
800 if (!empty($rating)) {
801 $schema['aggregateRating'] = $rating;
802 }
803
804 // Add reviews (recommended)
805 $reviews = $this->get_product_reviews($post);
806 if (!empty($reviews)) {
807 $schema['review'] = $reviews;
808 }
809
810 return $schema;
811 }
812
813 /**
814 * Generate Event schema markup (placeholder)
815 *
816 * @since 1.0.0
817 * @param \WP_Post $post WordPress post object
818 * @return array Schema markup
819 */
820 private function generate_event_schema(\WP_Post $post): array {
821 // Basic Event schema - can be extended based on requirements
822 return $this->generate_webpage_schema('WebPage', $post);
823 }
824
825 /**
826 * Get publisher schema (Organization or Person)
827 *
828 * @since 1.0.0
829 * @return array Publisher schema
830 */
831 private function get_publisher_schema(): array {
832 $site_name = get_bloginfo('name');
833 $site_url = home_url();
834
835 $publisher = [
836 '@type' => 'Organization',
837 'name' => $site_name,
838 'url' => $site_url,
839 ];
840
841 // Add logo if available
842 $custom_logo_id = get_theme_mod('custom_logo');
843 if ($custom_logo_id) {
844 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
845 if ($logo_url) {
846 $publisher['logo'] = [
847 '@type' => 'ImageObject',
848 'url' => $logo_url,
849 ];
850 }
851 }
852
853 return $publisher;
854 }
855
856 /**
857 * Get product description
858 *
859 * @since 1.0.0
860 * @param \WP_Post $post WordPress post object
861 * @return string Product description
862 */
863 private function get_product_description(\WP_Post $post): string {
864 // Try custom meta field first
865 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
866
867 // Fallback to excerpt or content
868 if (empty($description)) {
869 $description = get_the_excerpt($post);
870 }
871
872 if (empty($description)) {
873 $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post->post_content, 30);
874 }
875
876 return wp_strip_all_tags($description);
877 }
878
879 /**
880 * Get product image
881 *
882 * @since 1.0.0
883 * @param \WP_Post $post WordPress post object
884 * @return array|string Product image data
885 */
886 private function get_product_image(\WP_Post $post) {
887 // Try featured image first
888 if (has_post_thumbnail($post)) {
889 $image_id = get_post_thumbnail_id($post);
890 $image_url = wp_get_attachment_image_url($image_id, 'full');
891
892 if ($image_url) {
893 $image_meta = wp_get_attachment_metadata($image_id);
894
895 // SVGs, offloaded media and failed metadata regeneration all
896 // report no dimensions. Omit the keys entirely — a literal JSON
897 // null is an invalid value that Google flags, which is what the
898 // previous `: null` fallback emitted (#471). Matches
899 // Schema_Builder::format_image_schema().
900 $image_object = [
901 '@type' => 'ImageObject',
902 'url' => $image_url,
903 ];
904
905 if (!empty($image_meta['width'])) {
906 $image_object['width'] = (int) $image_meta['width'];
907 }
908
909 if (!empty($image_meta['height'])) {
910 $image_object['height'] = (int) $image_meta['height'];
911 }
912
913 return $image_object;
914 }
915 }
916
917 // Try custom meta field
918 $custom_image = get_post_meta($post->ID, '_thinkrank_product_image', true);
919 if (!empty($custom_image)) {
920 return $custom_image;
921 }
922
923 return '';
924 }
925
926 /**
927 * Get product SKU
928 *
929 * @since 1.0.0
930 * @param \WP_Post $post WordPress post object
931 * @return string Product SKU
932 */
933 private function get_product_sku(\WP_Post $post): string {
934 // Try custom meta field
935 $sku = get_post_meta($post->ID, '_thinkrank_product_sku', true);
936
937 // Try WooCommerce if available
938 if (empty($sku) && function_exists('wc_get_product')) {
939 $product = wc_get_product($post->ID);
940 if ($product) {
941 $sku = $product->get_sku();
942 }
943 }
944
945 return (string) $sku;
946 }
947
948 /**
949 * Get product brand
950 *
951 * @since 1.0.0
952 * @param \WP_Post $post WordPress post object
953 * @return string Product brand
954 */
955 private function get_product_brand(\WP_Post $post): string {
956 // Try custom meta field
957 $brand = get_post_meta($post->ID, '_thinkrank_product_brand', true);
958
959 // Try WooCommerce brand taxonomy if available
960 if (empty($brand) && taxonomy_exists('product_brand')) {
961 $terms = get_the_terms($post->ID, 'product_brand');
962 if (!empty($terms) && !is_wp_error($terms)) {
963 $brand = $terms[0]->name;
964 }
965 }
966
967 return (string) $brand;
968 }
969
970 /**
971 * Get product offers
972 *
973 * @since 1.0.0
974 * @param \WP_Post $post WordPress post object
975 * @return array Product offers data
976 */
977 private function get_product_offers(\WP_Post $post): array {
978 $offers = [
979 '@type' => 'Offer',
980 'url' => get_permalink($post),
981 ];
982
983 // Get price
984 $price = get_post_meta($post->ID, '_thinkrank_product_price', true);
985
986 // Try WooCommerce if available
987 if (empty($price) && function_exists('wc_get_product')) {
988 $product = wc_get_product($post->ID);
989 if ($product) {
990 $price = $product->get_price();
991 }
992 }
993
994 if (!empty($price)) {
995 $offers['price'] = (string) $price;
996 }
997
998 // Get currency
999 $currency = get_post_meta($post->ID, '_thinkrank_product_currency', true);
1000
1001 // Try WooCommerce currency if available
1002 if (empty($currency) && function_exists('get_woocommerce_currency')) {
1003 $currency = get_woocommerce_currency();
1004 }
1005
1006 // Default to USD
1007 if (empty($currency)) {
1008 $currency = 'USD';
1009 }
1010
1011 $offers['priceCurrency'] = $currency;
1012
1013 // Get availability
1014 $availability = get_post_meta($post->ID, '_thinkrank_product_availability', true);
1015
1016 // Try WooCommerce if available
1017 if (empty($availability) && function_exists('wc_get_product')) {
1018 $product = wc_get_product($post->ID);
1019 if ($product) {
1020 $availability = $product->is_in_stock() ? 'InStock' : 'OutOfStock';
1021 }
1022 }
1023
1024 // Default to InStock
1025 if (empty($availability)) {
1026 $availability = 'InStock';
1027 }
1028
1029 // Ensure proper schema.org URL format
1030 if (strpos($availability, 'https://schema.org/') !== 0) {
1031 $offers['availability'] = 'https://schema.org/' . $availability;
1032 } else {
1033 $offers['availability'] = $availability;
1034 }
1035
1036 // Add price valid until if available
1037 $price_valid_until = get_post_meta($post->ID, '_thinkrank_product_price_valid_until', true);
1038 if (!empty($price_valid_until)) {
1039 $offers['priceValidUntil'] = $price_valid_until;
1040 }
1041
1042 return $offers;
1043 }
1044
1045 /**
1046 * Get product aggregate rating
1047 *
1048 * @since 1.0.0
1049 * @param \WP_Post $post WordPress post object
1050 * @return array Product rating data
1051 */
1052 private function get_product_rating(\WP_Post $post): array {
1053 $rating = [];
1054
1055 // Try custom meta fields
1056 $rating_value = get_post_meta($post->ID, '_thinkrank_product_rating_value', true);
1057 $rating_count = get_post_meta($post->ID, '_thinkrank_product_rating_count', true);
1058
1059 // Try WooCommerce if available
1060 if ((empty($rating_value) || empty($rating_count)) && function_exists('wc_get_product')) {
1061 $product = wc_get_product($post->ID);
1062 if ($product) {
1063 $wc_rating_count = $product->get_rating_count();
1064 $wc_average = $product->get_average_rating();
1065
1066 if ($wc_rating_count > 0 && $wc_average > 0) {
1067 $rating_value = $wc_average;
1068 $rating_count = $wc_rating_count;
1069 }
1070 }
1071 }
1072
1073 // Only return rating if we have both value and count
1074 if (!empty($rating_value) && !empty($rating_count)) {
1075 $rating = [
1076 '@type' => 'AggregateRating',
1077 'ratingValue' => (string) $rating_value,
1078 'reviewCount' => (int) $rating_count,
1079 'bestRating' => '5',
1080 ];
1081 }
1082
1083 return $rating;
1084 }
1085
1086 /**
1087 * Get product reviews
1088 *
1089 * @since 1.0.0
1090 * @param \WP_Post $post WordPress post object
1091 * @return array Product reviews data
1092 */
1093 private function get_product_reviews(\WP_Post $post): array {
1094 $reviews = [];
1095
1096 // Try WooCommerce reviews if available
1097 if (function_exists('wc_get_product')) {
1098 $product = wc_get_product($post->ID);
1099 if ($product) {
1100 $comments = get_comments([
1101 'post_id' => $post->ID,
1102 'status' => 'approve',
1103 'type' => 'review',
1104 'number' => 5, // Limit to 5 most recent reviews
1105 ]);
1106
1107 foreach ($comments as $comment) {
1108 $rating = get_comment_meta($comment->comment_ID, 'rating', true);
1109
1110 if (!empty($rating)) {
1111 $reviews[] = [
1112 '@type' => 'Review',
1113 'reviewRating' => [
1114 '@type' => 'Rating',
1115 'ratingValue' => (string) $rating,
1116 'bestRating' => '5',
1117 ],
1118 'author' => [
1119 '@type' => 'Person',
1120 'name' => $comment->comment_author,
1121 ],
1122 'reviewBody' => wp_strip_all_tags($comment->comment_content),
1123 'datePublished' => get_comment_date('c', $comment),
1124 ];
1125 }
1126 }
1127 }
1128 }
1129
1130 // Try custom meta field for manual reviews
1131 if (empty($reviews)) {
1132 $custom_reviews = get_post_meta($post->ID, '_thinkrank_product_reviews', true);
1133 if (!empty($custom_reviews) && is_array($custom_reviews)) {
1134 $reviews = $custom_reviews;
1135 }
1136 }
1137
1138 return $reviews;
1139 }
1140
1141 /**
1142 * Register generated schema with the request's schema graph.
1143 *
1144 * Replaces the direct echo this class used to do: the graph arbitrates
1145 * between this post-type-wide schema and the Schema Manager's per-post
1146 * deployment, then emits one linked @graph (#355).
1147 *
1148 * @since 1.32.0
1149 * @param array $schema Schema markup array
1150 * @param string $schema_type Schema @type
1151 * @param string $source Producer key used for precedence
1152 * @return void
1153 */
1154 private function register_schema(array $schema, string $schema_type, string $source): void {
1155 if (empty($schema)) {
1156 return;
1157 }
1158
1159 if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
1160 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-schema-graph.php';
1161 }
1162
1163 Schema_Graph::instance()->add_primary($schema, $schema_type, $source);
1164 }
1165 }
1166