PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 +292 -30 2.0.1 → 2.10.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 */
@@ -104,8 +130,22 @@
104 130 }
105 131
106 132 $settings = $this->get_global_seo_settings($post_type);
107 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 +
108 148 return [];
109 149 }
110 150
111 151 // A per-post DEPLOYED Product schema duplicates WooCommerce's markup
@@ -153,8 +193,17 @@
153 193 * @since 1.0.0
154 194 * @return void
155 195 */
156 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 +
157 206 // Archives get a CollectionPage schema instead of the per-post-type one
158 207 if (!is_singular()) {
159 208 $this->output_archive_schema();
160 209 return;
@@ -231,9 +280,22 @@
231 280 $name = $posts_page_id ? get_the_title($posts_page_id) : __('Blog', 'thinkrank');
232 281 $url = $posts_page_id ? (string) get_permalink($posts_page_id) : home_url('/');
233 282 } elseif (is_post_type_archive()) {
234 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.
235 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) {
236 298 return;
237 299 }
238 300 $name = $post_type_object->labels->name ?? $post_type_object->label;
239 301 $url = (string) get_post_type_archive_link($post_type_object->name);
@@ -278,10 +340,12 @@
278 340 'url' => home_url('/'),
279 341 ],
280 342 ];
281 343
282 - $description = trim(wp_strip_all_tags($description));
283 - 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) {
284 348 $schema['description'] = $description;
285 349 }
286 350
287 351 /**
@@ -314,8 +378,16 @@
314 378 * @param string $post_type Post type slug.
315 379 * @return bool True when structured data would be output for this post type.
316 380 */
317 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 +
318 390 $settings = $this->get_global_seo_settings($post_type);
319 391
320 392 return !empty($settings['schema_type']);
321 393 }
@@ -320,8 +392,27 @@
320 392 return !empty($settings['schema_type']);
321 393 }
322 394
323 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 + /**
324 415 * Get Global SEO settings for a specific post type
325 416 *
326 417 * @since 1.0.0
327 418 * @param string $post_type Post type
@@ -460,12 +551,15 @@
460 551 'datePublished' => get_the_date('c', $post),
461 552 'dateModified' => get_the_modified_date('c', $post),
462 553 ];
463 554
464 - // Add description
465 - $excerpt = get_the_excerpt($post);
466 - if (!empty($excerpt)) {
467 - $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;
468 562 }
469 563
470 564 // Add author
471 565 $author_id = $post->post_author;
@@ -534,9 +628,9 @@
534 628 }
535 629 require_once $builder_file;
536 630 }
537 631
538 - $excerpt = get_the_excerpt($post);
632 + $excerpt = $this->post_excerpt_text($post);
539 633
540 634 $builder = new \ThinkRank\SEO\Schema_Builder();
541 635 $schema = $builder->build_schema(
542 636 'FAQPage',
@@ -541,9 +635,9 @@
541 635 $schema = $builder->build_schema(
542 636 'FAQPage',
543 637 [
544 638 'title' => get_the_title($post),
545 - 'content' => $post->post_content,
639 + 'content' => \ThinkRank\SEO\Builder_Content::visible_content($post),
546 640 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '',
547 641 'url' => get_permalink($post),
548 642 ],
549 643 get_post_type($post) === 'page' ? 'page' : 'post'
@@ -613,12 +707,13 @@
613 707 'datePublished' => get_the_date('c', $post),
614 708 'dateModified' => get_the_modified_date('c', $post),
615 709 ];
616 710
617 - // Add description
618 - $excerpt = get_the_excerpt($post);
619 - if (!empty($excerpt)) {
620 - $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;
621 716 }
622 717
623 718 // Add featured image if available
624 719 if (has_post_thumbnail($post)) {
@@ -653,9 +748,9 @@
653 748 // Add caption/description
654 749 $caption = wp_get_attachment_caption($post->ID);
655 750 if (!empty($caption)) {
656 751 $schema['caption'] = $caption;
657 - $schema['description'] = $caption;
752 + $schema['description'] = self::normalize_description((string) $caption);
658 753 }
659 754
660 755 // Add dimensions
661 756 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
@@ -683,18 +778,18 @@
683 778 'name' => get_the_title($post),
684 779 'url' => get_permalink($post),
685 780 ];
686 781
687 - // Add description
688 - $description = get_the_excerpt($post);
689 - if (empty($description)) {
690 - $caption = wp_get_attachment_caption($post->ID);
691 - if (!empty($caption)) {
692 - $description = $caption;
693 - }
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 + );
694 789 }
695 - if (!empty($description)) {
696 - $schema['description'] = wp_strip_all_tags($description);
790 + if ('' !== $description) {
791 + $schema['description'] = $description;
697 792 }
698 793
699 794 // For video attachments, add contentUrl
700 795 if ($post->post_type === 'attachment') {
@@ -850,18 +945,31 @@
850 945 private function get_product_description(\WP_Post $post): string {
851 946 // Try custom meta field first
852 947 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
853 948
854 - // 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).
855 955 if (empty($description)) {
856 - $description = get_the_excerpt($post);
956 + $description = $this->schema_description($post);
857 957 }
858 958
859 959 if (empty($description)) {
860 - $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt((string) $post->post_content, 30);
960 + $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
961 + \ThinkRank\SEO\Builder_Content::visible_content($post),
962 + 30
963 + );
861 964 }
862 965
863 - 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);
864 972 }
865 973
866 974 /**
867 975 * Get product image
@@ -878,15 +986,27 @@
878 986
879 987 if ($image_url) {
880 988 $image_meta = wp_get_attachment_metadata($image_id);
881 989
882 - 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 = [
883 996 '@type' => 'ImageObject',
884 997 'url' => $image_url,
885 - // SVGs report 0x0 — send null rather than a zero dimension.
886 - 'width' => !empty($image_meta['width']) ? (int) $image_meta['width'] : null,
887 - 'height' => !empty($image_meta['height']) ? (int) $image_meta['height'] : null,
888 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;
889 1009 }
890 1010 }
891 1011
892 1012 // Try custom meta field
@@ -1110,8 +1230,150 @@
1110 1230 }
1111 1231 }
1112 1232
1113 1233 return $reviews;
1234 + }
1235 +
1236 + /**
1237 + * The post's excerpt, taken from content the page actually renders.
1238 + *
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;
1114 1376 }
1115 1377
1116 1378 /**
1117 1379 * Register generated schema with the request's schema graph.