PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.11.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 All 52 releases
← All changes | includes/frontend/class-global-seo-schema-output.php +501 -42 1.29.0 → 2.11.0 View file →
@@ -46,8 +46,34 @@
46 46 */
47 47 private const SCHEMA_CONTEXT = 'https://schema.org';
48 48
49 49 /**
50 + * Returns the description already resolved for this request, or null.
51 + *
52 + * Injected rather than resolved here, because the chain behind it (post
53 + * meta, global template, archive, site-identity default, derived excerpt,
54 + * tagline) reads request state that Seo_Manager owns. Duplicating it would
55 + * be a second implementation to keep in step; this way schema and the meta
56 + * tags cannot disagree (#766).
57 + *
58 + * @since 2.10.0
59 + * @var callable|null
60 + */
61 + private $description_resolver = null;
62 +
63 + /**
64 + * Supply the request's resolved description.
65 + *
66 + * @since 2.10.0
67 + *
68 + * @param callable $resolver Returns string|null.
69 + * @return void
70 + */
71 + public function set_description_resolver(callable $resolver): void {
72 + $this->description_resolver = $resolver;
73 + }
74 +
75 + /**
50 76 * Initialize the schema output
51 77 *
52 78 * @since 1.0.0
53 79 */
@@ -53,11 +79,116 @@
53 79 */
54 80 public function init(): void {
55 81 // Hook into wp_head to output schema markup
56 82 add_action('wp_head', [$this, 'output_global_seo_schema'], 15);
83 +
84 + // One Product entity per product page: when ThinkRank emits the
85 + // Product schema (the default for WooCommerce products), WooCommerce
86 + // core's own JSON-LD must stand down, or the page carries two
87 + // aggregateRating blocks and Search Console raises the critical
88 + // "Review has multiple aggregate ratings" error. Registered eagerly
89 + // and decided lazily inside the callback, because WooCommerce
90 + // generates its data during the product template render — which on
91 + // block themes can run before wp_head, too early for a flag set at
92 + // output time to exist yet.
93 + add_filter('woocommerce_structured_data_product', [$this, 'suppress_woocommerce_product_schema'], 20, 2);
57 94 }
58 95
59 96 /**
97 + * Yield WooCommerce's Product structured data when ThinkRank emits the
98 + * Product entity for the page being viewed.
99 + *
100 + * Mirrors what other SEO plugins do with WC_Structured_Data: exactly one
101 + * plugin may describe the product. Suppression is surgical — only the
102 + * queried product on its own singular view, only when this class's
103 + * settings resolution says a Product schema will be generated (explicit
104 + * or the WooCommerce default), and WooCommerce's breadcrumb and other
105 + * structured data are never touched. With ThinkRank's product schema
106 + * disabled or set to another type, WooCommerce's markup passes through
107 + * unchanged.
108 + *
109 + * @since 2.0.1
110 + * @param array $markup WooCommerce's generated Product markup.
111 + * @param mixed $product WC_Product being described.
112 + * @return array Original markup, or empty to suppress.
113 + */
114 + public function suppress_woocommerce_product_schema($markup, $product = null) {
115 + if (!is_array($markup) || !is_singular()) {
116 + return $markup;
117 + }
118 +
119 + // Only the main product of this page — a card grid or related-products
120 + // widget describing other products is not ours to silence.
121 + $queried_id = (int) get_queried_object_id();
122 + $product_id = is_object($product) && method_exists($product, 'get_id') ? (int) $product->get_id() : 0;
123 + if (!$queried_id || !$product_id || $queried_id !== $product_id) {
124 + return $markup;
125 + }
126 +
127 + $post_type = (string) get_post_type($queried_id);
128 + if ($post_type === '') {
129 + return $markup;
130 + }
131 +
132 + $settings = $this->get_global_seo_settings($post_type);
133 + if (($settings['schema_type'] ?? '') === 'Product') {
134 + // ...but only if this class is actually going to emit it. The
135 + // per-content-type Schema switch (#660) makes
136 + // output_global_seo_schema() return before it builds anything, so
137 + // claiming the entity here as well left the page with NO product
138 + // structured data at all — strictly worse than the duplicate this
139 + // method exists to prevent, and the opposite of what the docblock
140 + // above promises for "ThinkRank's product schema disabled".
141 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
142 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
143 + true
144 + )) {
145 + return $markup;
146 + }
147 +
148 + return [];
149 + }
150 +
151 + // A per-post DEPLOYED Product schema duplicates WooCommerce's markup
152 + // just the same, even when the post-type-wide setting points elsewhere.
153 + // Checked second because the default path above answers without a
154 + // query; this one is a single indexed lookup and only runs on the
155 + // rare configured-away sites.
156 + if ($this->post_has_deployed_product_schema($queried_id)) {
157 + return [];
158 + }
159 +
160 + return $markup;
161 + }
162 +
163 + /**
164 + * Whether an active per-post Product schema deployment exists for a post.
165 + *
166 + * Reads the deployment table directly rather than constructing
167 + * Schema_Management_System — this runs inside WooCommerce's structured
168 + * data filter on product pages, where spinning up the full manager (and
169 + * its builder) to answer a yes/no question would be waste. Query shape
170 + * matches get_deployed_schemas(): active rows for the post context.
171 + *
172 + * @since 2.0.1
173 + * @param int $post_id Post to check.
174 + * @return bool
175 + */
176 + private function post_has_deployed_product_schema(int $post_id): bool {
177 + global $wpdb;
178 +
179 + $table = $wpdb->prefix . 'thinkrank_seo_schema';
180 +
181 + // 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.
182 + $found = $wpdb->get_var($wpdb->prepare(
183 + "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.
184 + $post_id
185 + ));
186 +
187 + return '1' === (string) $found;
188 + }
189 +
190 + /**
60 191 * Output JSON-LD schema markup based on Global SEO settings
61 192 *
62 193 * @since 1.0.0
63 194 * @return void
@@ -62,8 +193,17 @@
62 193 * @since 1.0.0
63 194 * @return void
64 195 */
65 196 public function output_global_seo_schema(): void {
197 + // Per-content-type schema switch. 'inherit' (the default) keeps schema
198 + // on, exactly as before the matrix existed (#660).
199 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
200 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
201 + true
202 + )) {
203 + return;
204 + }
205 +
66 206 // Archives get a CollectionPage schema instead of the per-post-type one
67 207 if (!is_singular()) {
68 208 $this->output_archive_schema();
69 209 return;
@@ -114,10 +254,12 @@
114 254 if (empty($schema)) {
115 255 return;
116 256 }
117 257
118 - // Output the schema markup
119 - $this->output_schema_markup($schema, $schema_type);
258 + // Register as a candidate for the page's single page-level entity. The
259 + // Schema Manager's per-post deployment outranks this post-type-wide
260 + // default when both describe the same page (#355).
261 + $this->register_schema($schema, $schema_type, 'global_seo');
120 262 }
121 263
122 264 /**
123 265 * Output CollectionPage schema for archive contexts.
@@ -138,9 +280,22 @@
138 280 $name = $posts_page_id ? get_the_title($posts_page_id) : __('Blog', 'thinkrank');
139 281 $url = $posts_page_id ? (string) get_permalink($posts_page_id) : home_url('/');
140 282 } elseif (is_post_type_archive()) {
141 283 $post_type_object = get_queried_object();
284 +
285 + // WooCommerce maps the shop archive onto a real page, so
286 + // get_queried_object() returns that WP_Post while
287 + // is_post_type_archive() is still true. Bailing here left every
288 + // store's main archive with no CollectionPage (#466). Fall back to
289 + // the query var, exactly as the canonical resolver already does.
142 290 if (!$post_type_object instanceof \WP_Post_Type) {
291 + $queried_post_type = (string) get_query_var('post_type');
292 + $post_type_object = $queried_post_type
293 + ? get_post_type_object($queried_post_type)
294 + : null;
295 + }
296 +
297 + if (!$post_type_object instanceof \WP_Post_Type) {
143 298 return;
144 299 }
145 300 $name = $post_type_object->labels->name ?? $post_type_object->label;
146 301 $url = (string) get_post_type_archive_link($post_type_object->name);
@@ -164,8 +319,17 @@
164 319 if (empty($url)) {
165 320 return;
166 321 }
167 322
323 + // Page 2 of an archive is a different URL and must be a different node.
324 + // The link above is always the un-paginated one, so Schema_Graph::base_url()
325 + // minted the identical #collectionpage and #breadcrumb @id on every
326 + // page — distinct URLs claiming the same node identity (#397).
327 + $url = \ThinkRank\Frontend\SEO_Manager::with_pagination(
328 + (string) $url,
329 + \ThinkRank\Frontend\SEO_Manager::current_page_number()
330 + );
331 +
168 332 $schema = [
169 333 '@context' => self::SCHEMA_CONTEXT,
170 334 '@type' => 'CollectionPage',
171 335 'name' => $name,
@@ -176,10 +340,12 @@
176 340 'url' => home_url('/'),
177 341 ],
178 342 ];
179 343
180 - $description = trim(wp_strip_all_tags($description));
181 - if (!empty($description)) {
344 + // Normalised like every other description: an entity or a trailing
345 + // excerpt marker is as wrong in a CollectionPage as anywhere (#766).
346 + $description = self::normalize_description((string) $description);
347 + if ('' !== $description) {
182 348 $schema['description'] = $description;
183 349 }
184 350
185 351 /**
@@ -194,9 +360,9 @@
194 360 if (empty($schema)) {
195 361 return;
196 362 }
197 363
198 - $this->output_schema_markup($schema, 'CollectionPage');
364 + $this->register_schema($schema, 'CollectionPage', 'global_seo');
199 365 }
200 366
201 367 /**
202 368 * Whether ThinkRank would emit structured data for a given post type.
@@ -212,8 +378,16 @@
212 378 * @param string $post_type Post type slug.
213 379 * @return bool True when structured data would be output for this post type.
214 380 */
215 381 public function would_output_schema(string $post_type): bool {
382 + if (!\ThinkRank\SEO\Content_Type_Settings::is_enabled(
383 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
384 + $post_type,
385 + true
386 + )) {
387 + return false;
388 + }
389 +
216 390 $settings = $this->get_global_seo_settings($post_type);
217 391
218 392 return !empty($settings['schema_type']);
219 393 }
@@ -218,8 +392,27 @@
218 392 return !empty($settings['schema_type']);
219 393 }
220 394
221 395 /**
396 + * Whether this post type has a SAVED schema type, ignoring the built-in
397 + * per-post-type default.
398 + *
399 + * would_output_schema() answers "will JSON-LD be emitted?", which the
400 + * fallback in get_global_seo_settings() makes true for every public post
401 + * type. The audit needs the different question "has the user configured
402 + * anything?", so this reads the stored option without the default merge.
403 + *
404 + * @since 2.2.0
405 + * @param string $post_type Post type.
406 + * @return bool True when an explicit schema_type is stored for this type.
407 + */
408 + public function has_explicit_schema_type(string $post_type): bool {
409 + $all_settings = get_option(self::OPTION_NAME, []);
410 +
411 + return !empty($all_settings[$post_type]['schema_type']);
412 + }
413 +
414 + /**
222 415 * Get Global SEO settings for a specific post type
223 416 *
224 417 * @since 1.0.0
225 418 * @param string $post_type Post type
@@ -309,12 +502,14 @@
309 502 case 'ScholarlyArticle':
310 503 case 'TechArticle':
311 504 return $this->generate_article_schema($type, $post);
312 505
506 + case 'FAQPage':
507 + return $this->generate_faq_schema($post);
508 +
313 509 case 'WebPage':
314 510 case 'AboutPage':
315 511 case 'ContactPage':
316 - case 'FAQPage':
317 512 case 'ProfilePage':
318 513 return $this->generate_webpage_schema($type, $post);
319 514
320 515 case 'ImageObject':
@@ -356,12 +551,15 @@
356 551 'datePublished' => get_the_date('c', $post),
357 552 'dateModified' => get_the_modified_date('c', $post),
358 553 ];
359 554
360 - // Add description
361 - $excerpt = get_the_excerpt($post);
362 - if (!empty($excerpt)) {
363 - $schema['description'] = wp_strip_all_tags($excerpt);
555 + // Add description. Prefers the request's resolved description so the
556 + // article describes itself the same way in JSON-LD as in the head
557 + // (#766); a Bricks page's stored `post_content` is not on the page, so
558 + // the excerpt fallback must not describe it either (#651).
559 + $description = $this->schema_description($post);
560 + if ('' !== $description) {
561 + $schema['description'] = $description;
364 562 }
365 563
366 564 // Add author
367 565 $author_id = $post->post_author;
@@ -404,8 +602,96 @@
404 602 return $schema;
405 603 }
406 604
407 605 /**
606 + * Generate FAQPage schema markup
607 + *
608 + * FAQPage previously fell through to generate_webpage_schema(), which emits a
609 + * WebPage-shaped object labelled @type FAQPage with no mainEntity — invalid for
610 + * rich results. Delegate to Schema_Builder instead, which owns the FAQ question
611 + * extraction already used by the deploy path, rather than growing a second
612 + * FAQ implementation here.
613 + *
614 + * Unlike the deploy path, this runs automatically on every post of the type with
615 + * no human reviewing the result, so questions that don't actually read as
616 + * questions are dropped and a page with none left falls back to WebPage — an
617 + * FAQPage with an empty mainEntity is worse than a valid WebPage.
618 + *
619 + * @since 1.32.0
620 + * @param \WP_Post $post WordPress post object
621 + * @return array Schema markup
622 + */
623 + private function generate_faq_schema(\WP_Post $post): array {
624 + if (!class_exists('ThinkRank\\SEO\\Schema_Builder')) {
625 + $builder_file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-builder.php';
626 + if (!file_exists($builder_file)) {
627 + return $this->generate_webpage_schema('WebPage', $post);
628 + }
629 + require_once $builder_file;
630 + }
631 +
632 + $excerpt = $this->post_excerpt_text($post);
633 +
634 + $builder = new \ThinkRank\SEO\Schema_Builder();
635 + $schema = $builder->build_schema(
636 + 'FAQPage',
637 + [
638 + 'title' => get_the_title($post),
639 + 'content' => \ThinkRank\SEO\Builder_Content::visible_content($post),
640 + 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '',
641 + 'url' => get_permalink($post),
642 + ],
643 + get_post_type($post) === 'page' ? 'page' : 'post'
644 + );
645 +
646 + if (!empty($schema['_error'])) {
647 + return $this->generate_webpage_schema('WebPage', $post);
648 + }
649 +
650 + $schema['mainEntity'] = $this->filter_faq_entities($schema['mainEntity'] ?? []);
651 +
652 + // No usable Q&A pairs — emit a valid WebPage rather than an empty FAQPage.
653 + if (empty($schema['mainEntity'])) {
654 + return $this->generate_webpage_schema('WebPage', $post);
655 + }
656 +
657 + $schema['datePublished'] = get_the_date('c', $post);
658 + $schema['dateModified'] = get_the_modified_date('c', $post);
659 +
660 + return $schema;
661 + }
662 +
663 + /**
664 + * Keep only FAQ entities that genuinely read as a question/answer pair.
665 + *
666 + * Schema_Builder's content extraction falls back to a heading-followed-by-paragraph
667 + * pattern, which on an ordinary page matches every section and would fabricate Q&A
668 + * that never appears on the page as such.
669 + *
670 + * @since 1.32.0
671 + * @param array $entities Candidate mainEntity entries
672 + * @return array Filtered entries
673 + */
674 + private function filter_faq_entities(array $entities): array {
675 + $filtered = [];
676 +
677 + foreach ($entities as $entity) {
678 + $question = isset($entity['name']) ? trim((string) $entity['name']) : '';
679 + $answer = isset($entity['acceptedAnswer']['text'])
680 + ? trim((string) $entity['acceptedAnswer']['text'])
681 + : '';
682 +
683 + if ($question === '' || $answer === '' || strpos($question, '?') === false) {
684 + continue;
685 + }
686 +
687 + $filtered[] = $entity;
688 + }
689 +
690 + return array_values($filtered);
691 + }
692 +
693 + /**
408 694 * Generate WebPage schema markup
409 695 *
410 696 * @since 1.0.0
411 697 * @param string $type WebPage type
@@ -421,12 +707,13 @@
421 707 'datePublished' => get_the_date('c', $post),
422 708 'dateModified' => get_the_modified_date('c', $post),
423 709 ];
424 710
425 - // Add description
426 - $excerpt = get_the_excerpt($post);
427 - if (!empty($excerpt)) {
428 - $schema['description'] = wp_strip_all_tags($excerpt);
711 + // Add description, preferring the one already resolved for this
712 + // request over core's auto excerpt (#766).
713 + $description = $this->schema_description($post);
714 + if ('' !== $description) {
715 + $schema['description'] = $description;
429 716 }
430 717
431 718 // Add featured image if available
432 719 if (has_post_thumbnail($post)) {
@@ -461,9 +748,9 @@
461 748 // Add caption/description
462 749 $caption = wp_get_attachment_caption($post->ID);
463 750 if (!empty($caption)) {
464 751 $schema['caption'] = $caption;
465 - $schema['description'] = $caption;
752 + $schema['description'] = self::normalize_description((string) $caption);
466 753 }
467 754
468 755 // Add dimensions
469 756 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
@@ -491,18 +778,18 @@
491 778 'name' => get_the_title($post),
492 779 'url' => get_permalink($post),
493 780 ];
494 781
495 - // Add description
496 - $description = get_the_excerpt($post);
497 - if (empty($description)) {
498 - $caption = wp_get_attachment_caption($post->ID);
499 - if (!empty($caption)) {
500 - $description = $caption;
501 - }
782 + // Add description. Same resolution as the page-level types (#766); an
783 + // attachment's caption remains the last resort.
784 + $description = $this->schema_description($post);
785 + if ('' === $description) {
786 + $description = self::normalize_description(
787 + (string) wp_get_attachment_caption($post->ID)
788 + );
502 789 }
503 - if (!empty($description)) {
504 - $schema['description'] = wp_strip_all_tags($description);
790 + if ('' !== $description) {
791 + $schema['description'] = $description;
505 792 }
506 793
507 794 // For video attachments, add contentUrl
508 795 if ($post->post_type === 'attachment') {
@@ -658,18 +945,31 @@
658 945 private function get_product_description(\WP_Post $post): string {
659 946 // Try custom meta field first
660 947 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
661 948
662 - // Fallback to excerpt or content
949 + // Then the description resolved for this request, so a product with a
950 + // hand-written meta description does not describe itself differently
951 + // in its Product node than in the head (#766). schema_description()
952 + // falls through to the excerpt on its own, and on a Bricks page that
953 + // excerpt comes from the visible body rather than the discarded
954 + // `post_content` (#651).
663 955 if (empty($description)) {
664 - $description = get_the_excerpt($post);
956 + $description = $this->schema_description($post);
665 957 }
666 958
667 959 if (empty($description)) {
668 - $description = wp_trim_words(wp_strip_all_tags($post->post_content), 30);
960 + $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
961 + \ThinkRank\SEO\Builder_Content::visible_content($post),
962 + 30
963 + );
669 964 }
670 965
671 - return wp_strip_all_tags($description);
966 + // Normalised like every other description rather than merely stripped.
967 + // `_thinkrank_product_description` is the branch a product author is
968 + // most likely to be using, and it reached the Product node verbatim:
969 + // an `&` stayed an entity and a trailing `[…]` stayed a marker,
970 + // which is the bug this was supposed to fix (#766).
971 + return self::normalize_description((string) $description);
672 972 }
673 973
674 974 /**
675 975 * Get product image
@@ -686,15 +986,27 @@
686 986
687 987 if ($image_url) {
688 988 $image_meta = wp_get_attachment_metadata($image_id);
689 989
690 - return [
990 + // SVGs, offloaded media and failed metadata regeneration all
991 + // report no dimensions. Omit the keys entirely — a literal JSON
992 + // null is an invalid value that Google flags, which is what the
993 + // previous `: null` fallback emitted (#471). Matches
994 + // Schema_Builder::format_image_schema().
995 + $image_object = [
691 996 '@type' => 'ImageObject',
692 997 'url' => $image_url,
693 - // SVGs report 0x0 — send null rather than a zero dimension.
694 - 'width' => !empty($image_meta['width']) ? (int) $image_meta['width'] : null,
695 - 'height' => !empty($image_meta['height']) ? (int) $image_meta['height'] : null,
696 998 ];
999 +
1000 + if (!empty($image_meta['width'])) {
1001 + $image_object['width'] = (int) $image_meta['width'];
1002 + }
1003 +
1004 + if (!empty($image_meta['height'])) {
1005 + $image_object['height'] = (int) $image_meta['height'];
1006 + }
1007 +
1008 + return $image_object;
697 1009 }
698 1010 }
699 1011
700 1012 // Try custom meta field
@@ -921,23 +1233,170 @@
921 1233 return $reviews;
922 1234 }
923 1235
924 1236 /**
925 - * Output schema markup as JSON-LD
1237 + * The post's excerpt, taken from content the page actually renders.
926 1238 *
927 - * @since 1.0.0
928 - * @param array $schema Schema data
929 - * @param string $schema_type Schema type name
1239 + * `get_the_excerpt()` falls back to trimming `post_content`, which a Bricks
1240 + * page discards — so on one of those it describes text no visitor sees. A
1241 + * hand-written excerpt is the author's own summary and still wins, because
1242 + * `superseding_excerpt_source()` yields nothing for a post that has one
1243 + * (#651).
1244 + *
1245 + * @since 2.3.1
1246 + * @param \WP_Post $post Post being described.
1247 + * @return string
1248 + */
1249 + private function post_excerpt_text(\WP_Post $post): string {
1250 + $superseding = \ThinkRank\SEO\Builder_Content::superseding_excerpt_source($post);
1251 +
1252 + return '' !== $superseding
1253 + ? \ThinkRank\SEO\Pattern_Resolver::derive_excerpt($superseding, 30)
1254 + : (string) get_the_excerpt($post);
1255 + }
1256 +
1257 + /**
1258 + * The description a schema node should carry for a post.
1259 + *
1260 + * Prefers the description ThinkRank already resolved for this request —
1261 + * the same value behind `<meta name="description">`, og:description and
1262 + * twitter:description, with the author's own meta at the top of its
1263 + * fallback chain. Schema used the auto excerpt instead, so a page with a
1264 + * hand-written description described itself one way to crawlers reading
1265 + * the head and another way to answer engines reading the JSON-LD (#766).
1266 + *
1267 + * The resolver is only consulted for the post the request is actually
1268 + * about. A node describing some other post (a related item, a listing
1269 + * entry) must not inherit this page's description, so those keep deriving
1270 + * their own excerpt.
1271 + *
1272 + * @since 2.10.0
1273 + *
1274 + * @param \WP_Post $post Post being described.
1275 + * @return string Description, or '' when nothing resolves.
1276 + */
1277 + private function schema_description(\WP_Post $post): string {
1278 + if (is_callable($this->description_resolver) && $this->describes_queried_object($post)) {
1279 + $resolved = (string) call_user_func($this->description_resolver);
1280 +
1281 + if ('' !== trim($resolved)) {
1282 + return self::normalize_description($resolved);
1283 + }
1284 + }
1285 +
1286 + return self::normalize_description($this->post_excerpt_text($post));
1287 + }
1288 +
1289 + /**
1290 + * Whether this post is the one the current request is about.
1291 + *
1292 + * @since 2.10.0
1293 + *
1294 + * @param \WP_Post $post Post being described.
1295 + * @return bool
1296 + */
1297 + private function describes_queried_object(\WP_Post $post): bool {
1298 + if (!function_exists('is_singular') || !is_singular()) {
1299 + return false;
1300 + }
1301 +
1302 + return (int) $post->ID === (int) get_queried_object_id();
1303 + }
1304 +
1305 + /**
1306 + * Make a description fit to appear in JSON-LD.
1307 + *
1308 + * Delegates to Seo_Text so the Schema Manager's builder, whose deployed
1309 + * nodes outrank this class's, normalises exactly the same way (#766).
1310 + *
1311 + * @since 2.10.0
1312 + *
1313 + * @param string $description Raw description.
1314 + * @return string
1315 + */
1316 + private static function normalize_description(string $description): string {
1317 + return \ThinkRank\Core\Seo_Text::normalize_schema_text($description);
1318 + }
1319 +
1320 + /**
1321 + * Types a deployed node describes with the post's own description.
1322 + *
1323 + * Schema_Builder fills `description` for these from the post excerpt or
1324 + * content and nothing else; the Schema Manager form has no description
1325 + * field for them. Types with such a field (Product, Event, HowTo,
1326 + * SoftwareApplication, VideoObject, Person) are absent on purpose: what the
1327 + * author typed there is theirs, not a stale copy of the page summary.
1328 + *
1329 + * @since 2.10.0
1330 + * @var string[]
1331 + */
1332 + private const POST_DESCRIBED_TYPES = [
1333 + 'WebPage', 'AboutPage', 'ContactPage', 'ProfilePage',
1334 + 'Article', 'BlogPosting', 'NewsArticle', 'TechnicalArticle', 'ScholarlyArticle', 'Report',
1335 + ];
1336 +
1337 + /**
1338 + * Give a deployed node the description the automatic node would carry.
1339 + *
1340 + * A node deployed through the Schema Manager is a snapshot taken when the
1341 + * author pressed Deploy, and it outranks the node this class builds. So a
1342 + * page that deployed AboutPage, ContactPage or ProfilePage (#624), or an
1343 + * Article, published a frozen excerpt instead of the description the head
1344 + * resolves, and kept it after the meta description was edited. Replacing
1345 + * it here, at output, makes the two nodes agree and fixes existing
1346 + * deployments without a redeploy.
1347 + *
1348 + * Leaves the node alone when it is not about the queried post, or when
1349 + * nothing resolves, so the stored value still stands in that case.
1350 + *
1351 + * @since 2.10.0
1352 + *
1353 + * @param array $node Deployed schema node.
1354 + * @param string $schema_type Deployed schema type.
1355 + * @param \WP_Post $post Post the node was deployed on.
1356 + * @return array
1357 + */
1358 + public function refresh_deployed_description(array $node, string $schema_type, \WP_Post $post): array {
1359 + $type = '' !== $schema_type ? $schema_type : (string) ($node['@type'] ?? '');
1360 +
1361 + if (!in_array($type, self::POST_DESCRIBED_TYPES, true)) {
1362 + return $node;
1363 + }
1364 +
1365 + if (!is_callable($this->description_resolver) || !$this->describes_queried_object($post)) {
1366 + return $node;
1367 + }
1368 +
1369 + $resolved = self::normalize_description((string) call_user_func($this->description_resolver));
1370 +
1371 + if ('' !== $resolved) {
1372 + $node['description'] = $resolved;
1373 + }
1374 +
1375 + return $node;
1376 + }
1377 +
1378 + /**
1379 + * Register generated schema with the request's schema graph.
1380 + *
1381 + * Replaces the direct echo this class used to do: the graph arbitrates
1382 + * between this post-type-wide schema and the Schema Manager's per-post
1383 + * deployment, then emits one linked @graph (#355).
1384 + *
1385 + * @since 1.32.0
1386 + * @param array $schema Schema markup array
1387 + * @param string $schema_type Schema @type
1388 + * @param string $source Producer key used for precedence
930 1389 * @return void
931 1390 */
932 - private function output_schema_markup(array $schema, string $schema_type): void {
1391 + private function register_schema(array $schema, string $schema_type, string $source): void {
933 1392 if (empty($schema)) {
934 1393 return;
935 1394 }
936 1395
937 - echo '<!-- ThinkRank Global SEO: ' . esc_html($schema_type) . ' Schema -->' . "\n";
938 - echo '<script type="application/ld+json">' . "\n";
939 - echo wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . "\n";
940 - echo '</script>' . "\n";
941 - echo '<!-- /ThinkRank Global SEO: ' . esc_html($schema_type) . ' Schema -->' . "\n";
1396 + if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
1397 + require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-schema-graph.php';
1398 + }
1399 +
1400 + Schema_Graph::instance()->add_primary($schema, $schema_type, $source);
942 1401 }
943 1402 }