← 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. |