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

1,212 lines 42.1 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. A Bricks page's stored `post_content` is not on the
497 // page, so core's derived excerpt must not describe it (#651).
498 $excerpt = $this->post_excerpt_text($post);
499 if (!empty($excerpt)) {
500 $schema['description'] = wp_strip_all_tags($excerpt);
501 }
502
503 // Add author
504 $author_id = $post->post_author;
505 if ($author_id) {
506 $schema['author'] = [
507 '@type' => 'Person',
508 'name' => get_the_author_meta('display_name', $author_id),
509 'url' => get_author_posts_url($author_id),
510 ];
511 }
512
513 // Add publisher (site info)
514 $schema['publisher'] = $this->get_publisher_schema();
515
516 // Add featured image if available
517 if (has_post_thumbnail($post)) {
518 $image_id = get_post_thumbnail_id($post);
519 $image_url = wp_get_attachment_image_url($image_id, 'full');
520 if ($image_url) {
521 $schema['image'] = [
522 '@type' => 'ImageObject',
523 'url' => $image_url,
524 ];
525
526 // Add image dimensions if available
527 $image_meta = wp_get_attachment_metadata($image_id);
528 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
529 $schema['image']['width'] = $image_meta['width'];
530 $schema['image']['height'] = $image_meta['height'];
531 }
532 }
533 }
534
535 // Add main entity of page
536 $schema['mainEntityOfPage'] = [
537 '@type' => 'WebPage',
538 '@id' => get_permalink($post),
539 ];
540
541 return $schema;
542 }
543
544 /**
545 * Generate FAQPage schema markup
546 *
547 * FAQPage previously fell through to generate_webpage_schema(), which emits a
548 * WebPage-shaped object labelled @type FAQPage with no mainEntity — invalid for
549 * rich results. Delegate to Schema_Builder instead, which owns the FAQ question
550 * extraction already used by the deploy path, rather than growing a second
551 * FAQ implementation here.
552 *
553 * Unlike the deploy path, this runs automatically on every post of the type with
554 * no human reviewing the result, so questions that don't actually read as
555 * questions are dropped and a page with none left falls back to WebPage — an
556 * FAQPage with an empty mainEntity is worse than a valid WebPage.
557 *
558 * @since 1.32.0
559 * @param \WP_Post $post WordPress post object
560 * @return array Schema markup
561 */
562 private function generate_faq_schema(\WP_Post $post): array {
563 if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) {
564 $builder_file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php';
565 if (!file_exists($builder_file)) {
566 return $this->generate_webpage_schema('WebPage', $post);
567 }
568 require_once $builder_file;
569 }
570
571 $excerpt = $this->post_excerpt_text($post);
572
573 $builder = new \ThinkRank\SEO\Schema_Builder();
574 $schema = $builder->build_schema(
575 'FAQPage',
576 [
577 'title' => get_the_title($post),
578 'content' => \ThinkRank\SEO\Builder_Content::visible_content($post),
579 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '',
580 'url' => get_permalink($post),
581 ],
582 get_post_type($post) === 'page' ? 'page' : 'post'
583 );
584
585 if (!empty($schema['_error'])) {
586 return $this->generate_webpage_schema('WebPage', $post);
587 }
588
589 $schema['mainEntity'] = $this->filter_faq_entities($schema['mainEntity'] ?? []);
590
591 // No usable Q&A pairs — emit a valid WebPage rather than an empty FAQPage.
592 if (empty($schema['mainEntity'])) {
593 return $this->generate_webpage_schema('WebPage', $post);
594 }
595
596 $schema['datePublished'] = get_the_date('c', $post);
597 $schema['dateModified'] = get_the_modified_date('c', $post);
598
599 return $schema;
600 }
601
602 /**
603 * Keep only FAQ entities that genuinely read as a question/answer pair.
604 *
605 * Schema_Builder's content extraction falls back to a heading-followed-by-paragraph
606 * pattern, which on an ordinary page matches every section and would fabricate Q&A
607 * that never appears on the page as such.
608 *
609 * @since 1.32.0
610 * @param array $entities Candidate mainEntity entries
611 * @return array Filtered entries
612 */
613 private function filter_faq_entities(array $entities): array {
614 $filtered = [];
615
616 foreach ($entities as $entity) {
617 $question = isset($entity['name']) ? trim((string) $entity['name']) : '';
618 $answer = isset($entity['acceptedAnswer']['text'])
619 ? trim((string) $entity['acceptedAnswer']['text'])
620 : '';
621
622 if ($question === '' || $answer === '' || strpos($question, '?') === false) {
623 continue;
624 }
625
626 $filtered[] = $entity;
627 }
628
629 return array_values($filtered);
630 }
631
632 /**
633 * Generate WebPage schema markup
634 *
635 * @since 1.0.0
636 * @param string $type WebPage type
637 * @param \WP_Post $post WordPress post object
638 * @return array Schema markup
639 */
640 private function generate_webpage_schema(string $type, \WP_Post $post): array {
641 $schema = [
642 '@context' => self::SCHEMA_CONTEXT,
643 '@type' => $type,
644 'name' => get_the_title($post),
645 'url' => get_permalink($post),
646 'datePublished' => get_the_date('c', $post),
647 'dateModified' => get_the_modified_date('c', $post),
648 ];
649
650 // Add description
651 $excerpt = $this->post_excerpt_text($post);
652 if (!empty($excerpt)) {
653 $schema['description'] = wp_strip_all_tags($excerpt);
654 }
655
656 // Add featured image if available
657 if (has_post_thumbnail($post)) {
658 $image_url = get_the_post_thumbnail_url($post, 'full');
659 if ($image_url) {
660 $schema['image'] = $image_url;
661 }
662 }
663
664 return $schema;
665 }
666
667 /**
668 * Generate ImageObject schema markup
669 *
670 * @since 1.0.0
671 * @param \WP_Post $post WordPress post object (attachment)
672 * @return array Schema markup
673 */
674 private function generate_image_schema(\WP_Post $post): array {
675 $image_url = wp_get_attachment_url($post->ID);
676 $image_meta = wp_get_attachment_metadata($post->ID);
677
678 $schema = [
679 '@context' => self::SCHEMA_CONTEXT,
680 '@type' => 'ImageObject',
681 'contentUrl' => $image_url,
682 'url' => get_permalink($post),
683 'name' => get_the_title($post),
684 ];
685
686 // Add caption/description
687 $caption = wp_get_attachment_caption($post->ID);
688 if (!empty($caption)) {
689 $schema['caption'] = $caption;
690 $schema['description'] = $caption;
691 }
692
693 // Add dimensions
694 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
695 $schema['width'] = $image_meta['width'];
696 $schema['height'] = $image_meta['height'];
697 }
698
699 // Add upload date
700 $schema['uploadDate'] = get_the_date('c', $post);
701
702 return $schema;
703 }
704
705 /**
706 * Generate VideoObject schema markup
707 *
708 * @since 1.0.0
709 * @param \WP_Post $post WordPress post object (attachment or post with video)
710 * @return array Schema markup
711 */
712 private function generate_video_schema(\WP_Post $post): array {
713 $schema = [
714 '@context' => self::SCHEMA_CONTEXT,
715 '@type' => 'VideoObject',
716 'name' => get_the_title($post),
717 'url' => get_permalink($post),
718 ];
719
720 // Add description
721 $description = $this->post_excerpt_text($post);
722 if (empty($description)) {
723 $caption = wp_get_attachment_caption($post->ID);
724 if (!empty($caption)) {
725 $description = $caption;
726 }
727 }
728 if (!empty($description)) {
729 $schema['description'] = wp_strip_all_tags($description);
730 }
731
732 // For video attachments, add contentUrl
733 if ($post->post_type === 'attachment') {
734 $video_url = wp_get_attachment_url($post->ID);
735 if ($video_url) {
736 $schema['contentUrl'] = $video_url;
737 }
738
739 // Add upload date
740 $schema['uploadDate'] = get_the_date('c', $post);
741 }
742
743 // Add thumbnail/poster image if available
744 if (has_post_thumbnail($post)) {
745 $thumbnail_url = get_the_post_thumbnail_url($post, 'full');
746 if ($thumbnail_url) {
747 $schema['thumbnailUrl'] = $thumbnail_url;
748 }
749 }
750
751 // Add duration if available from meta
752 $duration = get_post_meta($post->ID, '_thinkrank_video_duration', true);
753 if (!empty($duration)) {
754 $schema['duration'] = $duration; // Should be in ISO 8601 format (e.g., PT1M30S)
755 }
756
757 // Add embed URL if available from meta
758 $embed_url = get_post_meta($post->ID, '_thinkrank_video_embed_url', true);
759 if (!empty($embed_url)) {
760 $schema['embedUrl'] = $embed_url;
761 }
762
763 return $schema;
764 }
765
766 /**
767 * Generate Product schema markup
768 *
769 * Generates valid Schema.org Product markup with required and recommended properties.
770 * Supports custom meta fields and WooCommerce integration.
771 *
772 * @since 1.0.0
773 * @param \WP_Post $post WordPress post object
774 * @return array Schema markup
775 */
776 private function generate_product_schema(\WP_Post $post): array {
777 // Base Product schema with required properties
778 $schema = [
779 '@context' => self::SCHEMA_CONTEXT,
780 '@type' => 'Product',
781 'name' => get_the_title($post),
782 'url' => get_permalink($post),
783 ];
784
785 // Add description (required for valid Product schema)
786 $description = $this->get_product_description($post);
787 if (!empty($description)) {
788 $schema['description'] = $description;
789 }
790
791 // Add image (required for valid Product schema)
792 $image = $this->get_product_image($post);
793 if (!empty($image)) {
794 $schema['image'] = $image;
795 }
796
797 // Add SKU if available
798 $sku = $this->get_product_sku($post);
799 if (!empty($sku)) {
800 $schema['sku'] = $sku;
801 }
802
803 // Add brand (recommended)
804 $brand = $this->get_product_brand($post);
805 if (!empty($brand)) {
806 $schema['brand'] = [
807 '@type' => 'Brand',
808 'name' => $brand,
809 ];
810 }
811
812 // Add offers (required for valid Product schema)
813 $offers = $this->get_product_offers($post);
814 if (!empty($offers)) {
815 $schema['offers'] = $offers;
816 }
817
818 // Add aggregate rating (recommended)
819 $rating = $this->get_product_rating($post);
820 if (!empty($rating)) {
821 $schema['aggregateRating'] = $rating;
822 }
823
824 // Add reviews (recommended)
825 $reviews = $this->get_product_reviews($post);
826 if (!empty($reviews)) {
827 $schema['review'] = $reviews;
828 }
829
830 return $schema;
831 }
832
833 /**
834 * Generate Event schema markup (placeholder)
835 *
836 * @since 1.0.0
837 * @param \WP_Post $post WordPress post object
838 * @return array Schema markup
839 */
840 private function generate_event_schema(\WP_Post $post): array {
841 // Basic Event schema - can be extended based on requirements
842 return $this->generate_webpage_schema('WebPage', $post);
843 }
844
845 /**
846 * Get publisher schema (Organization or Person)
847 *
848 * @since 1.0.0
849 * @return array Publisher schema
850 */
851 private function get_publisher_schema(): array {
852 $site_name = get_bloginfo('name');
853 $site_url = home_url();
854
855 $publisher = [
856 '@type' => 'Organization',
857 'name' => $site_name,
858 'url' => $site_url,
859 ];
860
861 // Add logo if available
862 $custom_logo_id = get_theme_mod('custom_logo');
863 if ($custom_logo_id) {
864 $logo_url = wp_get_attachment_image_url($custom_logo_id, 'full');
865 if ($logo_url) {
866 $publisher['logo'] = [
867 '@type' => 'ImageObject',
868 'url' => $logo_url,
869 ];
870 }
871 }
872
873 return $publisher;
874 }
875
876 /**
877 * Get product description
878 *
879 * @since 1.0.0
880 * @param \WP_Post $post WordPress post object
881 * @return string Product description
882 */
883 private function get_product_description(\WP_Post $post): string {
884 // Try custom meta field first
885 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
886
887 // Fallback to excerpt or content. On a Bricks page the excerpt core
888 // derives comes from discarded `post_content`, so the visible body is
889 // used instead (#651).
890 if (empty($description)) {
891 $description = $this->post_excerpt_text($post);
892 }
893
894 if (empty($description)) {
895 $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
896 \ThinkRank\SEO\Builder_Content::visible_content($post),
897 30
898 );
899 }
900
901 return wp_strip_all_tags($description);
902 }
903
904 /**
905 * Get product image
906 *
907 * @since 1.0.0
908 * @param \WP_Post $post WordPress post object
909 * @return array|string Product image data
910 */
911 private function get_product_image(\WP_Post $post) {
912 // Try featured image first
913 if (has_post_thumbnail($post)) {
914 $image_id = get_post_thumbnail_id($post);
915 $image_url = wp_get_attachment_image_url($image_id, 'full');
916
917 if ($image_url) {
918 $image_meta = wp_get_attachment_metadata($image_id);
919
920 // SVGs, offloaded media and failed metadata regeneration all
921 // report no dimensions. Omit the keys entirely — a literal JSON
922 // null is an invalid value that Google flags, which is what the
923 // previous `: null` fallback emitted (#471). Matches
924 // Schema_Builder::format_image_schema().
925 $image_object = [
926 '@type' => 'ImageObject',
927 'url' => $image_url,
928 ];
929
930 if (!empty($image_meta['width'])) {
931 $image_object['width'] = (int) $image_meta['width'];
932 }
933
934 if (!empty($image_meta['height'])) {
935 $image_object['height'] = (int) $image_meta['height'];
936 }
937
938 return $image_object;
939 }
940 }
941
942 // Try custom meta field
943 $custom_image = get_post_meta($post->ID, '_thinkrank_product_image', true);
944 if (!empty($custom_image)) {
945 return $custom_image;
946 }
947
948 return '';
949 }
950
951 /**
952 * Get product SKU
953 *
954 * @since 1.0.0
955 * @param \WP_Post $post WordPress post object
956 * @return string Product SKU
957 */
958 private function get_product_sku(\WP_Post $post): string {
959 // Try custom meta field
960 $sku = get_post_meta($post->ID, '_thinkrank_product_sku', true);
961
962 // Try WooCommerce if available
963 if (empty($sku) && function_exists('wc_get_product')) {
964 $product = wc_get_product($post->ID);
965 if ($product) {
966 $sku = $product->get_sku();
967 }
968 }
969
970 return (string) $sku;
971 }
972
973 /**
974 * Get product brand
975 *
976 * @since 1.0.0
977 * @param \WP_Post $post WordPress post object
978 * @return string Product brand
979 */
980 private function get_product_brand(\WP_Post $post): string {
981 // Try custom meta field
982 $brand = get_post_meta($post->ID, '_thinkrank_product_brand', true);
983
984 // Try WooCommerce brand taxonomy if available
985 if (empty($brand) && taxonomy_exists('product_brand')) {
986 $terms = get_the_terms($post->ID, 'product_brand');
987 if (!empty($terms) && !is_wp_error($terms)) {
988 $brand = $terms[0]->name;
989 }
990 }
991
992 return (string) $brand;
993 }
994
995 /**
996 * Get product offers
997 *
998 * @since 1.0.0
999 * @param \WP_Post $post WordPress post object
1000 * @return array Product offers data
1001 */
1002 private function get_product_offers(\WP_Post $post): array {
1003 $offers = [
1004 '@type' => 'Offer',
1005 'url' => get_permalink($post),
1006 ];
1007
1008 // Get price
1009 $price = get_post_meta($post->ID, '_thinkrank_product_price', true);
1010
1011 // Try WooCommerce if available
1012 if (empty($price) && function_exists('wc_get_product')) {
1013 $product = wc_get_product($post->ID);
1014 if ($product) {
1015 $price = $product->get_price();
1016 }
1017 }
1018
1019 if (!empty($price)) {
1020 $offers['price'] = (string) $price;
1021 }
1022
1023 // Get currency
1024 $currency = get_post_meta($post->ID, '_thinkrank_product_currency', true);
1025
1026 // Try WooCommerce currency if available
1027 if (empty($currency) && function_exists('get_woocommerce_currency')) {
1028 $currency = get_woocommerce_currency();
1029 }
1030
1031 // Default to USD
1032 if (empty($currency)) {
1033 $currency = 'USD';
1034 }
1035
1036 $offers['priceCurrency'] = $currency;
1037
1038 // Get availability
1039 $availability = get_post_meta($post->ID, '_thinkrank_product_availability', true);
1040
1041 // Try WooCommerce if available
1042 if (empty($availability) && function_exists('wc_get_product')) {
1043 $product = wc_get_product($post->ID);
1044 if ($product) {
1045 $availability = $product->is_in_stock() ? 'InStock' : 'OutOfStock';
1046 }
1047 }
1048
1049 // Default to InStock
1050 if (empty($availability)) {
1051 $availability = 'InStock';
1052 }
1053
1054 // Ensure proper schema.org URL format
1055 if (strpos($availability, 'https://schema.org/') !== 0) {
1056 $offers['availability'] = 'https://schema.org/' . $availability;
1057 } else {
1058 $offers['availability'] = $availability;
1059 }
1060
1061 // Add price valid until if available
1062 $price_valid_until = get_post_meta($post->ID, '_thinkrank_product_price_valid_until', true);
1063 if (!empty($price_valid_until)) {
1064 $offers['priceValidUntil'] = $price_valid_until;
1065 }
1066
1067 return $offers;
1068 }
1069
1070 /**
1071 * Get product aggregate rating
1072 *
1073 * @since 1.0.0
1074 * @param \WP_Post $post WordPress post object
1075 * @return array Product rating data
1076 */
1077 private function get_product_rating(\WP_Post $post): array {
1078 $rating = [];
1079
1080 // Try custom meta fields
1081 $rating_value = get_post_meta($post->ID, '_thinkrank_product_rating_value', true);
1082 $rating_count = get_post_meta($post->ID, '_thinkrank_product_rating_count', true);
1083
1084 // Try WooCommerce if available
1085 if ((empty($rating_value) || empty($rating_count)) && function_exists('wc_get_product')) {
1086 $product = wc_get_product($post->ID);
1087 if ($product) {
1088 $wc_rating_count = $product->get_rating_count();
1089 $wc_average = $product->get_average_rating();
1090
1091 if ($wc_rating_count > 0 && $wc_average > 0) {
1092 $rating_value = $wc_average;
1093 $rating_count = $wc_rating_count;
1094 }
1095 }
1096 }
1097
1098 // Only return rating if we have both value and count
1099 if (!empty($rating_value) && !empty($rating_count)) {
1100 $rating = [
1101 '@type' => 'AggregateRating',
1102 'ratingValue' => (string) $rating_value,
1103 'reviewCount' => (int) $rating_count,
1104 'bestRating' => '5',
1105 ];
1106 }
1107
1108 return $rating;
1109 }
1110
1111 /**
1112 * Get product reviews
1113 *
1114 * @since 1.0.0
1115 * @param \WP_Post $post WordPress post object
1116 * @return array Product reviews data
1117 */
1118 private function get_product_reviews(\WP_Post $post): array {
1119 $reviews = [];
1120
1121 // Try WooCommerce reviews if available
1122 if (function_exists('wc_get_product')) {
1123 $product = wc_get_product($post->ID);
1124 if ($product) {
1125 $comments = get_comments([
1126 'post_id' => $post->ID,
1127 'status' => 'approve',
1128 'type' => 'review',
1129 'number' => 5, // Limit to 5 most recent reviews
1130 ]);
1131
1132 foreach ($comments as $comment) {
1133 $rating = get_comment_meta($comment->comment_ID, 'rating', true);
1134
1135 if (!empty($rating)) {
1136 $reviews[] = [
1137 '@type' => 'Review',
1138 'reviewRating' => [
1139 '@type' => 'Rating',
1140 'ratingValue' => (string) $rating,
1141 'bestRating' => '5',
1142 ],
1143 'author' => [
1144 '@type' => 'Person',
1145 'name' => $comment->comment_author,
1146 ],
1147 'reviewBody' => wp_strip_all_tags($comment->comment_content),
1148 'datePublished' => get_comment_date('c', $comment),
1149 ];
1150 }
1151 }
1152 }
1153 }
1154
1155 // Try custom meta field for manual reviews
1156 if (empty($reviews)) {
1157 $custom_reviews = get_post_meta($post->ID, '_thinkrank_product_reviews', true);
1158 if (!empty($custom_reviews) && is_array($custom_reviews)) {
1159 $reviews = $custom_reviews;
1160 }
1161 }
1162
1163 return $reviews;
1164 }
1165
1166 /**
1167 * The post's excerpt, taken from content the page actually renders.
1168 *
1169 * `get_the_excerpt()` falls back to trimming `post_content`, which a Bricks
1170 * page discards — so on one of those it describes text no visitor sees. A
1171 * hand-written excerpt is the author's own summary and still wins, because
1172 * `superseding_excerpt_source()` yields nothing for a post that has one
1173 * (#651).
1174 *
1175 * @since 2.3.1
1176 * @param \WP_Post $post Post being described.
1177 * @return string
1178 */
1179 private function post_excerpt_text(\WP_Post $post): string {
1180 $superseding = \ThinkRank\SEO\Builder_Content::superseding_excerpt_source($post);
1181
1182 return '' !== $superseding
1183 ? \ThinkRank\SEO\Pattern_Resolver::derive_excerpt($superseding, 30)
1184 : (string) get_the_excerpt($post);
1185 }
1186
1187 /**
1188 * Register generated schema with the request's schema graph.
1189 *
1190 * Replaces the direct echo this class used to do: the graph arbitrates
1191 * between this post-type-wide schema and the Schema Manager's per-post
1192 * deployment, then emits one linked @graph (#355).
1193 *
1194 * @since 1.32.0
1195 * @param array $schema Schema markup array
1196 * @param string $schema_type Schema @type
1197 * @param string $source Producer key used for precedence
1198 * @return void
1199 */
1200 private function register_schema(array $schema, string $schema_type, string $source): void {
1201 if (empty($schema)) {
1202 return;
1203 }
1204
1205 if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
1206 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-schema-graph.php';
1207 }
1208
1209 Schema_Graph::instance()->add_primary($schema, $schema_type, $source);
1210 }
1211 }
1212