PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/frontend/class-global-seo-schema-output.php +215 -13 2.0.0 → 2.9.0 View file →
@@ -53,11 +53,116 @@
53 53 */
54 54 public function init(): void {
55 55 // Hook into wp_head to output schema markup
56 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);
57 68 }
58 69
59 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 + // ...but only if this class is actually going to emit it. The
109 + // per-content-type Schema switch (#660) makes
110 + // output_global_seo_schema() return before it builds anything, so
111 + // claiming the entity here as well left the page with NO product
112 + // structured data at all — strictly worse than the duplicate this
113 + // method exists to prevent, and the opposite of what the docblock
114 + // above promises for "ThinkRank's product schema disabled".
115 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
116 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
117 + true
118 + )) {
119 + return $markup;
120 + }
121 +
122 + return [];
123 + }
124 +
125 + // A per-post DEPLOYED Product schema duplicates WooCommerce's markup
126 + // just the same, even when the post-type-wide setting points elsewhere.
127 + // Checked second because the default path above answers without a
128 + // query; this one is a single indexed lookup and only runs on the
129 + // rare configured-away sites.
130 + if ($this->post_has_deployed_product_schema($queried_id)) {
131 + return [];
132 + }
133 +
134 + return $markup;
135 + }
136 +
137 + /**
138 + * Whether an active per-post Product schema deployment exists for a post.
139 + *
140 + * Reads the deployment table directly rather than constructing
141 + * Schema_Management_System — this runs inside WooCommerce's structured
142 + * data filter on product pages, where spinning up the full manager (and
143 + * its builder) to answer a yes/no question would be waste. Query shape
144 + * matches get_deployed_schemas(): active rows for the post context.
145 + *
146 + * @since 2.0.1
147 + * @param int $post_id Post to check.
148 + * @return bool
149 + */
150 + private function post_has_deployed_product_schema(int $post_id): bool {
151 + global $wpdb;
152 +
153 + $table = $wpdb->prefix . 'thinkrank_seo_schema';
154 +
155 + // 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.
156 + $found = $wpdb->get_var($wpdb->prepare(
157 + "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.
158 + $post_id
159 + ));
160 +
161 + return '1' === (string) $found;
162 + }
163 +
164 + /**
60 165 * Output JSON-LD schema markup based on Global SEO settings
61 166 *
62 167 * @since 1.0.0
63 168 * @return void
@@ -62,8 +167,17 @@
62 167 * @since 1.0.0
63 168 * @return void
64 169 */
65 170 public function output_global_seo_schema(): void {
171 + // Per-content-type schema switch. 'inherit' (the default) keeps schema
172 + // on, exactly as before the matrix existed (#660).
173 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
174 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
175 + true
176 + )) {
177 + return;
178 + }
179 +
66 180 // Archives get a CollectionPage schema instead of the per-post-type one
67 181 if (!is_singular()) {
68 182 $this->output_archive_schema();
69 183 return;
@@ -140,9 +254,22 @@
140 254 $name = $posts_page_id ? get_the_title($posts_page_id) : __('Blog', 'thinkrank');
141 255 $url = $posts_page_id ? (string) get_permalink($posts_page_id) : home_url('/');
142 256 } elseif (is_post_type_archive()) {
143 257 $post_type_object = get_queried_object();
258 +
259 + // WooCommerce maps the shop archive onto a real page, so
260 + // get_queried_object() returns that WP_Post while
261 + // is_post_type_archive() is still true. Bailing here left every
262 + // store's main archive with no CollectionPage (#466). Fall back to
263 + // the query var, exactly as the canonical resolver already does.
144 264 if (!$post_type_object instanceof \WP_Post_Type) {
265 + $queried_post_type = (string) get_query_var('post_type');
266 + $post_type_object = $queried_post_type
267 + ? get_post_type_object($queried_post_type)
268 + : null;
269 + }
270 +
271 + if (!$post_type_object instanceof \WP_Post_Type) {
145 272 return;
146 273 }
147 274 $name = $post_type_object->labels->name ?? $post_type_object->label;
148 275 $url = (string) get_post_type_archive_link($post_type_object->name);
@@ -166,8 +293,17 @@
166 293 if (empty($url)) {
167 294 return;
168 295 }
169 296
297 + // Page 2 of an archive is a different URL and must be a different node.
298 + // The link above is always the un-paginated one, so Schema_Graph::base_url()
299 + // minted the identical #collectionpage and #breadcrumb @id on every
300 + // page — distinct URLs claiming the same node identity (#397).
301 + $url = \ThinkRank\Frontend\SEO_Manager::with_pagination(
302 + (string) $url,
303 + \ThinkRank\Frontend\SEO_Manager::current_page_number()
304 + );
305 +
170 306 $schema = [
171 307 '@context' => self::SCHEMA_CONTEXT,
172 308 '@type' => 'CollectionPage',
173 309 'name' => $name,
@@ -214,8 +350,16 @@
214 350 * @param string $post_type Post type slug.
215 351 * @return bool True when structured data would be output for this post type.
216 352 */
217 353 public function would_output_schema(string $post_type): bool {
354 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled(
355 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
356 + $post_type,
357 + true
358 + )) {
359 + return false;
360 + }
361 +
218 362 $settings = $this->get_global_seo_settings($post_type);
219 363
220 364 return !empty($settings['schema_type']);
221 365 }
@@ -220,8 +364,27 @@
220 364 return !empty($settings['schema_type']);
221 365 }
222 366
223 367 /**
368 + * Whether this post type has a SAVED schema type, ignoring the built-in
369 + * per-post-type default.
370 + *
371 + * would_output_schema() answers "will JSON-LD be emitted?", which the
372 + * fallback in get_global_seo_settings() makes true for every public post
373 + * type. The audit needs the different question "has the user configured
374 + * anything?", so this reads the stored option without the default merge.
375 + *
376 + * @since 2.2.0
377 + * @param string $post_type Post type.
378 + * @return bool True when an explicit schema_type is stored for this type.
379 + */
380 + public function has_explicit_schema_type(string $post_type): bool {
381 + $all_settings = get_option(self::OPTION_NAME, []);
382 +
383 + return !empty($all_settings[$post_type]['schema_type']);
384 + }
385 +
386 + /**
224 387 * Get Global SEO settings for a specific post type
225 388 *
226 389 * @since 1.0.0
227 390 * @param string $post_type Post type
@@ -360,10 +523,11 @@
360 523 'datePublished' => get_the_date('c', $post),
361 524 'dateModified' => get_the_modified_date('c', $post),
362 525 ];
363 526
364 - // Add description
365 - $excerpt = get_the_excerpt($post);
527 + // Add description. A Bricks page's stored `post_content` is not on the
528 + // page, so core's derived excerpt must not describe it (#651).
529 + $excerpt = $this->post_excerpt_text($post);
366 530 if (!empty($excerpt)) {
367 531 $schema['description'] = wp_strip_all_tags($excerpt);
368 532 }
369 533
@@ -434,9 +598,9 @@
434 598 }
435 599 require_once $builder_file;
436 600 }
437 601
438 - $excerpt = get_the_excerpt($post);
602 + $excerpt = $this->post_excerpt_text($post);
439 603
440 604 $builder = new \ThinkRank\SEO\Schema_Builder();
441 605 $schema = $builder->build_schema(
442 606 'FAQPage',
@@ -441,9 +605,9 @@
441 605 $schema = $builder->build_schema(
442 606 'FAQPage',
443 607 [
444 608 'title' => get_the_title($post),
445 - 'content' => $post->post_content,
609 + 'content' => \ThinkRank\SEO\Builder_Content::visible_content($post),
446 610 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '',
447 611 'url' => get_permalink($post),
448 612 ],
449 613 get_post_type($post) === 'page' ? 'page' : 'post'
@@ -514,9 +678,9 @@
514 678 'dateModified' => get_the_modified_date('c', $post),
515 679 ];
516 680
517 681 // Add description
518 - $excerpt = get_the_excerpt($post);
682 + $excerpt = $this->post_excerpt_text($post);
519 683 if (!empty($excerpt)) {
520 684 $schema['description'] = wp_strip_all_tags($excerpt);
521 685 }
522 686
@@ -584,9 +748,9 @@
584 748 'url' => get_permalink($post),
585 749 ];
586 750
587 751 // Add description
588 - $description = get_the_excerpt($post);
752 + $description = $this->post_excerpt_text($post);
589 753 if (empty($description)) {
590 754 $caption = wp_get_attachment_caption($post->ID);
591 755 if (!empty($caption)) {
592 756 $description = $caption;
@@ -750,15 +914,20 @@
750 914 private function get_product_description(\WP_Post $post): string {
751 915 // Try custom meta field first
752 916 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
753 917
754 - // Fallback to excerpt or content
918 + // Fallback to excerpt or content. On a Bricks page the excerpt core
919 + // derives comes from discarded `post_content`, so the visible body is
920 + // used instead (#651).
755 921 if (empty($description)) {
756 - $description = get_the_excerpt($post);
922 + $description = $this->post_excerpt_text($post);
757 923 }
758 924
759 925 if (empty($description)) {
760 - $description = wp_trim_words(wp_strip_all_tags($post->post_content), 30);
926 + $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
927 + \ThinkRank\SEO\Builder_Content::visible_content($post),
928 + 30
929 + );
761 930 }
762 931
763 932 return wp_strip_all_tags($description);
764 933 }
@@ -778,15 +947,27 @@
778 947
779 948 if ($image_url) {
780 949 $image_meta = wp_get_attachment_metadata($image_id);
781 950
782 - return [
951 + // SVGs, offloaded media and failed metadata regeneration all
952 + // report no dimensions. Omit the keys entirely — a literal JSON
953 + // null is an invalid value that Google flags, which is what the
954 + // previous `: null` fallback emitted (#471). Matches
955 + // Schema_Builder::format_image_schema().
956 + $image_object = [
783 957 '@type' => 'ImageObject',
784 958 'url' => $image_url,
785 - // SVGs report 0x0 — send null rather than a zero dimension.
786 - 'width' => !empty($image_meta['width']) ? (int) $image_meta['width'] : null,
787 - 'height' => !empty($image_meta['height']) ? (int) $image_meta['height'] : null,
788 959 ];
960 +
961 + if (!empty($image_meta['width'])) {
962 + $image_object['width'] = (int) $image_meta['width'];
963 + }
964 +
965 + if (!empty($image_meta['height'])) {
966 + $image_object['height'] = (int) $image_meta['height'];
967 + }
968 +
969 + return $image_object;
789 970 }
790 971 }
791 972
792 973 // Try custom meta field
@@ -1010,8 +1191,29 @@
1010 1191 }
1011 1192 }
1012 1193
1013 1194 return $reviews;
1195 + }
1196 +
1197 + /**
1198 + * The post's excerpt, taken from content the page actually renders.
1199 + *
1200 + * `get_the_excerpt()` falls back to trimming `post_content`, which a Bricks
1201 + * page discards — so on one of those it describes text no visitor sees. A
1202 + * hand-written excerpt is the author's own summary and still wins, because
1203 + * `superseding_excerpt_source()` yields nothing for a post that has one
1204 + * (#651).
1205 + *
1206 + * @since 2.3.1
1207 + * @param \WP_Post $post Post being described.
1208 + * @return string
1209 + */
1210 + private function post_excerpt_text(\WP_Post $post): string {
1211 + $superseding = \ThinkRank\SEO\Builder_Content::superseding_excerpt_source($post);
1212 +
1213 + return '' !== $superseding
1214 + ? \ThinkRank\SEO\Pattern_Resolver::derive_excerpt($superseding, 30)
1215 + : (string) get_the_excerpt($post);
1014 1216 }
1015 1217
1016 1218 /**
1017 1219 * Register generated schema with the request's schema graph.