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

1,185 lines 40.8 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 * Whether this post type has a SAVED schema type, ignoring the built-in
338 * per-post-type default.
339 *
340 * would_output_schema() answers "will JSON-LD be emitted?", which the
341 * fallback in get_global_seo_settings() makes true for every public post
342 * type. The audit needs the different question "has the user configured
343 * anything?", so this reads the stored option without the default merge.
344 *
345 * @since 2.2.0
346 * @param string $post_type Post type.
347 * @return bool True when an explicit schema_type is stored for this type.
348 */
349 public function has_explicit_schema_type(string $post_type): bool {
350 $all_settings = get_option(self::OPTION_NAME, []);
351
352 return !empty($all_settings[$post_type]['schema_type']);
353 }
354
355 /**
356 * Get Global SEO settings for a specific post type
357 *
358 * @since 1.0.0
359 * @param string $post_type Post type
360 * @return array Settings array
361 */
362 private function get_global_seo_settings(string $post_type): array {
363 $all_settings = get_option(self::OPTION_NAME, []);
364 $settings = $all_settings[$post_type] ?? [];
365
366 // Fall back to a sensible default schema type when nothing is saved for
367 // this post type, so structured data works out of the box on sites that
368 // never opened the Global SEO settings (e.g. migrated from Rank Math).
369 // An explicit saved schema_type always wins. Mirrors the per-post-type
370 // defaults the REST endpoint (Global_SEO_Endpoint::get_default_settings)
371 // exposes to the admin UI.
372 if (empty($settings['schema_type'])) {
373 $default = $this->get_default_schema_type($post_type);
374 if ($default !== null) {
375 $settings = array_merge($default, $settings);
376 }
377 }
378
379 return $settings;
380 }
381
382 /**
383 * Default schema type (and sub-type) for a post type when unconfigured.
384 *
385 * @since 1.15.x
386 * @param string $post_type Post type slug
387 * @return array|null ['schema_type' => ..., 'article_type' => ..., 'media_type' => ...] or null to emit nothing
388 */
389 private function get_default_schema_type(string $post_type): ?array {
390 switch ($post_type) {
391 case 'post':
392 return ['schema_type' => 'Article', 'article_type' => 'BlogPosting', 'media_type' => ''];
393 case 'page':
394 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
395 case 'attachment':
396 return ['schema_type' => 'Media', 'article_type' => '', 'media_type' => 'ImageObject'];
397 case 'product':
398 // Only claim Product schema when WooCommerce is actually present,
399 // so a generic CPT named "product" without WooCommerce still gets
400 // WebPage rather than an offers-less Product graph.
401 return class_exists('WooCommerce')
402 ? ['schema_type' => 'Product', 'article_type' => '', 'media_type' => '']
403 : ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
404 default:
405 // Public custom post types (e.g. BetterDocs `docs`) get WebPage.
406 $object = get_post_type_object($post_type);
407 if ($object && empty($object->public)) {
408 return null;
409 }
410 return ['schema_type' => 'WebPage', 'article_type' => '', 'media_type' => ''];
411 }
412 }
413
414 /**
415 * Generate schema markup based on schema type
416 *
417 * @since 1.0.0
418 * @param string $schema_type Schema type (e.g., 'Article', 'WebPage', 'Media')
419 * @param string $article_type Article type (e.g., 'BlogPosting', 'NewsArticle')
420 * @param string $media_type Media type (e.g., 'ImageObject', 'VideoObject')
421 * @param \WP_Post $post WordPress post object
422 * @return array Schema markup array
423 */
424 private function generate_schema(string $schema_type, string $article_type, string $media_type, \WP_Post $post): array {
425 // Determine the actual type to use based on schema_type and sub-types
426 $type = $schema_type;
427
428 // Use article_type if schema_type is 'Article' and article_type is specified
429 if ($schema_type === 'Article' && !empty($article_type)) {
430 $type = $article_type;
431 }
432
433 // Use media_type if schema_type is 'Media' and media_type is specified
434 if ($schema_type === 'Media' && !empty($media_type)) {
435 $type = $media_type;
436 }
437
438 // Generate schema based on type
439 switch ($type) {
440 case 'Article':
441 case 'BlogPosting':
442 case 'NewsArticle':
443 case 'ScholarlyArticle':
444 case 'TechArticle':
445 return $this->generate_article_schema($type, $post);
446
447 case 'FAQPage':
448 return $this->generate_faq_schema($post);
449
450 case 'WebPage':
451 case 'AboutPage':
452 case 'ContactPage':
453 case 'ProfilePage':
454 return $this->generate_webpage_schema($type, $post);
455
456 case 'ImageObject':
457 return $this->generate_image_schema($post);
458
459 case 'VideoObject':
460 return $this->generate_video_schema($post);
461
462 case 'Product':
463 return $this->generate_product_schema($post);
464
465 case 'Event':
466 return $this->generate_event_schema($post);
467
468 case 'Media':
469 // Fallback to ImageObject if Media is selected but no media_type specified
470 return $this->generate_image_schema($post);
471
472 default:
473 // Fallback to WebPage for unknown types
474 return $this->generate_webpage_schema('WebPage', $post);
475 }
476 }
477
478 /**
479 * Generate Article schema markup
480 *
481 * @since 1.0.0
482 * @param string $type Article type
483 * @param \WP_Post $post WordPress post object
484 * @return array Schema markup
485 */
486 private function generate_article_schema(string $type, \WP_Post $post): array {
487 $schema = [
488 '@context' => self::SCHEMA_CONTEXT,
489 '@type' => $type,
490 'headline' => get_the_title($post),
491 'url' => get_permalink($post),
492 'datePublished' => get_the_date('c', $post),
493 'dateModified' => get_the_modified_date('c', $post),
494 ];
495
496 // Add description
497 $excerpt = get_the_excerpt($post);
498 if (!empty($excerpt)) {
499 $schema['description'] = wp_strip_all_tags($excerpt);
500 }
501
502 // Add author
503 $author_id = $post->post_author;
504 if ($author_id) {
505 $schema['author'] = [
506 '@type' => 'Person',
507 'name' => get_the_author_meta('display_name', $author_id),
508 'url' => get_author_posts_url($author_id),
509 ];
510 }
511
512 // Add publisher (site info)
513 $schema['publisher'] = $this->get_publisher_schema();
514
515 // Add featured image if available
516 if (has_post_thumbnail($post)) {
517 $image_id = get_post_thumbnail_id($post);
518 $image_url = wp_get_attachment_image_url($image_id, 'full');
519 if ($image_url) {
520 $schema['image'] = [
521 '@type' => 'ImageObject',
522 'url' => $image_url,
523 ];
524
525 // Add image dimensions if available
526 $image_meta = wp_get_attachment_metadata($image_id);
527 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
528 $schema['image']['width'] = $image_meta['width'];
529 $schema['image']['height'] = $image_meta['height'];
530 }
531 }
532 }
533
534 // Add main entity of page
535 $schema['mainEntityOfPage'] = [
536 '@type' => 'WebPage',
537 '@id' => get_permalink($post),
538 ];
539
540 return $schema;
541 }
542
543 /**
544 * Generate FAQPage schema markup
545 *
546 * FAQPage previously fell through to generate_webpage_schema(), which emits a
547 * WebPage-shaped object labelled @type FAQPage with no mainEntity — invalid for
548 * rich results. Delegate to Schema_Builder instead, which owns the FAQ question
549 * extraction already used by the deploy path, rather than growing a second
550 * FAQ implementation here.
551 *
552 * Unlike the deploy path, this runs automatically on every post of the type with
553 * no human reviewing the result, so questions that don't actually read as
554 * questions are dropped and a page with none left falls back to WebPage — an
555 * FAQPage with an empty mainEntity is worse than a valid WebPage.
556 *
557 * @since 1.32.0
558 * @param \WP_Post $post WordPress post object
559 * @return array Schema markup
560 */
561 private function generate_faq_schema(\WP_Post $post): array {
562 if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) {
563 $builder_file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php';
564 if (!file_exists($builder_file)) {
565 return $this->generate_webpage_schema('WebPage', $post);
566 }
567 require_once $builder_file;
568 }
569
570 $excerpt = get_the_excerpt($post);
571
572 $builder = new \ThinkRank\SEO\Schema_Builder();
573 $schema = $builder->build_schema(
574 'FAQPage',
575 [
576 'title' => get_the_title($post),
577 'content' => $post->post_content,
578 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '',
579 'url' => get_permalink($post),
580 ],
581 get_post_type($post) === 'page' ? 'page' : 'post'
582 );
583
584 if (!empty($schema['_error'])) {
585 return $this->generate_webpage_schema('WebPage', $post);
586 }
587
588 $schema['mainEntity'] = $this->filter_faq_entities($schema['mainEntity'] ?? []);
589
590 // No usable Q&A pairs — emit a valid WebPage rather than an empty FAQPage.
591 if (empty($schema['mainEntity'])) {
592 return $this->generate_webpage_schema('WebPage', $post);
593 }
594
595 $schema['datePublished'] = get_the_date('c', $post);
596 $schema['dateModified'] = get_the_modified_date('c', $post);
597
598 return $schema;
599 }
600
601 /**
602 * Keep only FAQ entities that genuinely read as a question/answer pair.
603 *
604 * Schema_Builder's content extraction falls back to a heading-followed-by-paragraph
605 * pattern, which on an ordinary page matches every section and would fabricate Q&A
606 * that never appears on the page as such.
607 *
608 * @since 1.32.0
609 * @param array $entities Candidate mainEntity entries
610 * @return array Filtered entries
611 */
612 private function filter_faq_entities(array $entities): array {
613 $filtered = [];
614
615 foreach ($entities as $entity) {
616 $question = isset($entity['name']) ? trim((string) $entity['name']) : '';
617 $answer = isset($entity['acceptedAnswer']['text'])
618 ? trim((string) $entity['acceptedAnswer']['text'])
619 : '';
620
621 if ($question === '' || $answer === '' || strpos($question, '?') === false) {
622 continue;
623 }
624
625 $filtered[] = $entity;
626 }
627
628 return array_values($filtered);
629 }
630
631 /**
632 * Generate WebPage schema markup
633 *
634 * @since 1.0.0
635 * @param string $type WebPage type
636 * @param \WP_Post $post WordPress post object
637 * @return array Schema markup
638 */
639 private function generate_webpage_schema(string $type, \WP_Post $post): array {
640 $schema = [
641 '@context' => self::SCHEMA_CONTEXT,
642 '@type' => $type,
643 'name' => get_the_title($post),
644 'url' => get_permalink($post),
645 'datePublished' => get_the_date('c', $post),
646 'dateModified' => get_the_modified_date('c', $post),
647 ];
648
649 // Add description
650 $excerpt = get_the_excerpt($post);
651 if (!empty($excerpt)) {
652 $schema['description'] = wp_strip_all_tags($excerpt);
653 }
654
655 // Add featured image if available
656 if (has_post_thumbnail($post)) {
657 $image_url = get_the_post_thumbnail_url($post, 'full');
658 if ($image_url) {
659 $schema['image'] = $image_url;
660 }
661 }
662
663 return $schema;
664 }
665
666 /**
667 * Generate ImageObject schema markup
668 *
669 * @since 1.0.0
670 * @param \WP_Post $post WordPress post object (attachment)
671 * @return array Schema markup
672 */
673 private function generate_image_schema(\WP_Post $post): array {
674 $image_url = wp_get_attachment_url($post->ID);
675 $image_meta = wp_get_attachment_metadata($post->ID);
676
677 $schema = [
678 '@context' => self::SCHEMA_CONTEXT,
679 '@type' => 'ImageObject',
680 'contentUrl' => $image_url,
681 'url' => get_permalink($post),
682 'name' => get_the_title($post),
683 ];
684
685 // Add caption/description
686 $caption = wp_get_attachment_caption($post->ID);
687 if (!empty($caption)) {
688 $schema['caption'] = $caption;
689 $schema['description'] = $caption;
690 }
691
692 // Add dimensions
693 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
694 $schema['width'] = $image_meta['width'];
695 $schema['height'] = $image_meta['height'];
696 }
697
698 // Add upload date
699 $schema['uploadDate'] = get_the_date('c', $post);
700
701 return $schema;
702 }
703
704 /**
705 * Generate VideoObject schema markup
706 *
707 * @since 1.0.0
708 * @param \WP_Post $post WordPress post object (attachment or post with video)
709 * @return array Schema markup
710 */
711 private function generate_video_schema(\WP_Post $post): array {
712 $schema = [
713 '@context' => self::SCHEMA_CONTEXT,
714 '@type' => 'VideoObject',
715 'name' => get_the_title($post),
716 'url' => get_permalink($post),
717 ];
718
719 // Add description
720 $description = get_the_excerpt($post);
721 if (empty($description)) {
722 $caption = wp_get_attachment_caption($post->ID);
723 if (!empty($caption)) {
724 $description = $caption;
725 }
726 }
727 if (!empty($description)) {
728 $schema['description'] = wp_strip_all_tags($description);
729 }
730
731 // For video attachments, add contentUrl
732 if ($post->post_type === 'attachment') {
733 $video_url = wp_get_attachment_url($post->ID);
734 if ($video_url) {
735 $schema['contentUrl'] = $video_url;
736 }
737
738 // Add upload date
739 $schema['uploadDate'] = get_the_date('c', $post);
740 }
741
742 // Add thumbnail/poster image if available
743 if (has_post_thumbnail($post)) {
744 $thumbnail_url = get_the_post_thumbnail_url($post, 'full');
745 if ($thumbnail_url) {
746 $schema['thumbnailUrl'] = $thumbnail_url;
747 }
748 }
749
750 // Add duration if available from meta
751 $duration = get_post_meta($post->ID, '_thinkrank_video_duration', true);
752 if (!empty($duration)) {
753 $schema['duration'] = $duration; // Should be in ISO 8601 format (e.g., PT1M30S)
754 }
755
756 // Add embed URL if available from meta
757 $embed_url = get_post_meta($post->ID, '_thinkrank_video_embed_url', true);
758 if (!empty($embed_url)) {
759 $schema['embedUrl'] = $embed_url;
760 }
761
762 return $schema;
763 }
764
765 /**
766 * Generate Product schema markup
767 *
768 * Generates valid Schema.org Product markup with required and recommended properties.
769 * Supports custom meta fields and WooCommerce integration.
770 *
771 * @since 1.0.0
772 * @param \WP_Post $post WordPress post object
773 * @return array Schema markup
774 */
775 private function generate_product_schema(\WP_Post $post): array {
776 // Base Product schema with required properties
777 $schema = [
778 '@context' => self::SCHEMA_CONTEXT,
779 '@type' => 'Product',
780 'name' => get_the_title($post),
781 'url' => get_permalink($post),
782 ];
783
784 // Add description (required for valid Product schema)
785 $description = $this->get_product_description($post);
786 if (!empty($description)) {
787 $schema['description'] = $description;
788 }
789
790 // Add image (required for valid Product schema)
791 $image = $this->get_product_image($post);
792 if (!empty($image)) {
793 $schema['image'] = $image;
794 }
795
796 // Add SKU if available
797 $sku = $this->get_product_sku($post);
798 if (!empty($sku)) {
799 $schema['sku'] = $sku;
800 }
801
802 // Add brand (recommended)
803 $brand = $this->get_product_brand($post);
804 if (!empty($brand)) {
805 $schema['brand'] = [
806 '@type' => 'Brand',
807 'name' => $brand,
808 ];
809 }
810
811 // Add offers (required for valid Product schema)
812 $offers = $this->get_product_offers($post);
813 if (!empty($offers)) {
814 $schema['offers'] = $offers;
815 }
816
817 // Add aggregate rating (recommended)
818 $rating = $this->get_product_rating($post);
819 if (!empty($rating)) {
820 $schema['aggregateRating'] = $rating;
821 }
822
823 // Add reviews (recommended)
824 $reviews = $this->get_product_reviews($post);
825 if (!empty($reviews)) {
826 $schema['review'] = $reviews;
827 }
828
829 return $schema;
830 }
831
832 /**
833 * Generate Event schema markup (placeholder)
834 *
835 * @since 1.0.0
836 * @param \WP_Post $post WordPress post object
837 * @return array Schema markup
838 */
839 private function generate_event_schema(\WP_Post $post): array {
840 // Basic Event schema - can be extended based on requirements
841 return $this->generate_webpage_schema('WebPage', $post);
842 }
843
844 /**
845 * Get publisher schema (Organization or Person)
846 *
847 * @since 1.0.0
848 * @return array Publisher schema
849 */
850 private function get_publisher_schema(): array {
851 $site_name = get_bloginfo('name');
852 $site_url = home_url();
853
854 $publisher = [
855 '@type' => 'Organization',
856 'name' => $site_name,
857 'url' => $site_url,
858 ];
859
860 // Add logo if available
861 $custom_logo_id = get_theme_mod('custom_logo');
862 if ($custom_logo_id) {
863 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
864 if ($logo_url) {
865 $publisher['logo'] = [
866 '@type' => 'ImageObject',
867 'url' => $logo_url,
868 ];
869 }
870 }
871
872 return $publisher;
873 }
874
875 /**
876 * Get product description
877 *
878 * @since 1.0.0
879 * @param \WP_Post $post WordPress post object
880 * @return string Product description
881 */
882 private function get_product_description(\WP_Post $post): string {
883 // Try custom meta field first
884 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
885
886 // Fallback to excerpt or content
887 if (empty($description)) {
888 $description = get_the_excerpt($post);
889 }
890
891 if (empty($description)) {
892 $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post->post_content, 30);
893 }
894
895 return wp_strip_all_tags($description);
896 }
897
898 /**
899 * Get product image
900 *
901 * @since 1.0.0
902 * @param \WP_Post $post WordPress post object
903 * @return array|string Product image data
904 */
905 private function get_product_image(\WP_Post $post) {
906 // Try featured image first
907 if (has_post_thumbnail($post)) {
908 $image_id = get_post_thumbnail_id($post);
909 $image_url = wp_get_attachment_image_url($image_id, 'full');
910
911 if ($image_url) {
912 $image_meta = wp_get_attachment_metadata($image_id);
913
914 // SVGs, offloaded media and failed metadata regeneration all
915 // report no dimensions. Omit the keys entirely — a literal JSON
916 // null is an invalid value that Google flags, which is what the
917 // previous `: null` fallback emitted (#471). Matches
918 // Schema_Builder::format_image_schema().
919 $image_object = [
920 '@type' => 'ImageObject',
921 'url' => $image_url,
922 ];
923
924 if (!empty($image_meta['width'])) {
925 $image_object['width'] = (int) $image_meta['width'];
926 }
927
928 if (!empty($image_meta['height'])) {
929 $image_object['height'] = (int) $image_meta['height'];
930 }
931
932 return $image_object;
933 }
934 }
935
936 // Try custom meta field
937 $custom_image = get_post_meta($post->ID, '_thinkrank_product_image', true);
938 if (!empty($custom_image)) {
939 return $custom_image;
940 }
941
942 return '';
943 }
944
945 /**
946 * Get product SKU
947 *
948 * @since 1.0.0
949 * @param \WP_Post $post WordPress post object
950 * @return string Product SKU
951 */
952 private function get_product_sku(\WP_Post $post): string {
953 // Try custom meta field
954 $sku = get_post_meta($post->ID, '_thinkrank_product_sku', true);
955
956 // Try WooCommerce if available
957 if (empty($sku) && function_exists('wc_get_product')) {
958 $product = wc_get_product($post->ID);
959 if ($product) {
960 $sku = $product->get_sku();
961 }
962 }
963
964 return (string) $sku;
965 }
966
967 /**
968 * Get product brand
969 *
970 * @since 1.0.0
971 * @param \WP_Post $post WordPress post object
972 * @return string Product brand
973 */
974 private function get_product_brand(\WP_Post $post): string {
975 // Try custom meta field
976 $brand = get_post_meta($post->ID, '_thinkrank_product_brand', true);
977
978 // Try WooCommerce brand taxonomy if available
979 if (empty($brand) && taxonomy_exists('product_brand')) {
980 $terms = get_the_terms($post->ID, 'product_brand');
981 if (!empty($terms) && !is_wp_error($terms)) {
982 $brand = $terms[0]->name;
983 }
984 }
985
986 return (string) $brand;
987 }
988
989 /**
990 * Get product offers
991 *
992 * @since 1.0.0
993 * @param \WP_Post $post WordPress post object
994 * @return array Product offers data
995 */
996 private function get_product_offers(\WP_Post $post): array {
997 $offers = [
998 '@type' => 'Offer',
999 'url' => get_permalink($post),
1000 ];
1001
1002 // Get price
1003 $price = get_post_meta($post->ID, '_thinkrank_product_price', true);
1004
1005 // Try WooCommerce if available
1006 if (empty($price) && function_exists('wc_get_product')) {
1007 $product = wc_get_product($post->ID);
1008 if ($product) {
1009 $price = $product->get_price();
1010 }
1011 }
1012
1013 if (!empty($price)) {
1014 $offers['price'] = (string) $price;
1015 }
1016
1017 // Get currency
1018 $currency = get_post_meta($post->ID, '_thinkrank_product_currency', true);
1019
1020 // Try WooCommerce currency if available
1021 if (empty($currency) && function_exists('get_woocommerce_currency')) {
1022 $currency = get_woocommerce_currency();
1023 }
1024
1025 // Default to USD
1026 if (empty($currency)) {
1027 $currency = 'USD';
1028 }
1029
1030 $offers['priceCurrency'] = $currency;
1031
1032 // Get availability
1033 $availability = get_post_meta($post->ID, '_thinkrank_product_availability', true);
1034
1035 // Try WooCommerce if available
1036 if (empty($availability) && function_exists('wc_get_product')) {
1037 $product = wc_get_product($post->ID);
1038 if ($product) {
1039 $availability = $product->is_in_stock() ? 'InStock' : 'OutOfStock';
1040 }
1041 }
1042
1043 // Default to InStock
1044 if (empty($availability)) {
1045 $availability = 'InStock';
1046 }
1047
1048 // Ensure proper schema.org URL format
1049 if (strpos($availability, 'https://schema.org/') !== 0) {
1050 $offers['availability'] = 'https://schema.org/' . $availability;
1051 } else {
1052 $offers['availability'] = $availability;
1053 }
1054
1055 // Add price valid until if available
1056 $price_valid_until = get_post_meta($post->ID, '_thinkrank_product_price_valid_until', true);
1057 if (!empty($price_valid_until)) {
1058 $offers['priceValidUntil'] = $price_valid_until;
1059 }
1060
1061 return $offers;
1062 }
1063
1064 /**
1065 * Get product aggregate rating
1066 *
1067 * @since 1.0.0
1068 * @param \WP_Post $post WordPress post object
1069 * @return array Product rating data
1070 */
1071 private function get_product_rating(\WP_Post $post): array {
1072 $rating = [];
1073
1074 // Try custom meta fields
1075 $rating_value = get_post_meta($post->ID, '_thinkrank_product_rating_value', true);
1076 $rating_count = get_post_meta($post->ID, '_thinkrank_product_rating_count', true);
1077
1078 // Try WooCommerce if available
1079 if ((empty($rating_value) || empty($rating_count)) && function_exists('wc_get_product')) {
1080 $product = wc_get_product($post->ID);
1081 if ($product) {
1082 $wc_rating_count = $product->get_rating_count();
1083 $wc_average = $product->get_average_rating();
1084
1085 if ($wc_rating_count > 0 && $wc_average > 0) {
1086 $rating_value = $wc_average;
1087 $rating_count = $wc_rating_count;
1088 }
1089 }
1090 }
1091
1092 // Only return rating if we have both value and count
1093 if (!empty($rating_value) && !empty($rating_count)) {
1094 $rating = [
1095 '@type' => 'AggregateRating',
1096 'ratingValue' => (string) $rating_value,
1097 'reviewCount' => (int) $rating_count,
1098 'bestRating' => '5',
1099 ];
1100 }
1101
1102 return $rating;
1103 }
1104
1105 /**
1106 * Get product reviews
1107 *
1108 * @since 1.0.0
1109 * @param \WP_Post $post WordPress post object
1110 * @return array Product reviews data
1111 */
1112 private function get_product_reviews(\WP_Post $post): array {
1113 $reviews = [];
1114
1115 // Try WooCommerce reviews if available
1116 if (function_exists('wc_get_product')) {
1117 $product = wc_get_product($post->ID);
1118 if ($product) {
1119 $comments = get_comments([
1120 'post_id' => $post->ID,
1121 'status' => 'approve',
1122 'type' => 'review',
1123 'number' => 5, // Limit to 5 most recent reviews
1124 ]);
1125
1126 foreach ($comments as $comment) {
1127 $rating = get_comment_meta($comment->comment_ID, 'rating', true);
1128
1129 if (!empty($rating)) {
1130 $reviews[] = [
1131 '@type' => 'Review',
1132 'reviewRating' => [
1133 '@type' => 'Rating',
1134 'ratingValue' => (string) $rating,
1135 'bestRating' => '5',
1136 ],
1137 'author' => [
1138 '@type' => 'Person',
1139 'name' => $comment->comment_author,
1140 ],
1141 'reviewBody' => wp_strip_all_tags($comment->comment_content),
1142 'datePublished' => get_comment_date('c', $comment),
1143 ];
1144 }
1145 }
1146 }
1147 }
1148
1149 // Try custom meta field for manual reviews
1150 if (empty($reviews)) {
1151 $custom_reviews = get_post_meta($post->ID, '_thinkrank_product_reviews', true);
1152 if (!empty($custom_reviews) && is_array($custom_reviews)) {
1153 $reviews = $custom_reviews;
1154 }
1155 }
1156
1157 return $reviews;
1158 }
1159
1160 /**
1161 * Register generated schema with the request's schema graph.
1162 *
1163 * Replaces the direct echo this class used to do: the graph arbitrates
1164 * between this post-type-wide schema and the Schema Manager's per-post
1165 * deployment, then emits one linked @graph (#355).
1166 *
1167 * @since 1.32.0
1168 * @param array $schema Schema markup array
1169 * @param string $schema_type Schema @type
1170 * @param string $source Producer key used for precedence
1171 * @return void
1172 */
1173 private function register_schema(array $schema, string $schema_type, string $source): void {
1174 if (empty($schema)) {
1175 return;
1176 }
1177
1178 if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
1179 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-schema-graph.php';
1180 }
1181
1182 Schema_Graph::instance()->add_primary($schema, $schema_type, $source);
1183 }
1184 }
1185