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 +186 -26 2.7.0 → 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 */
@@ -314,10 +340,12 @@
314 340 'url' => home_url('/'),
315 341 ],
316 342 ];
317 343
318 - $description = trim(wp_strip_all_tags($description));
319 - 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) {
320 348 $schema['description'] = $description;
321 349 }
322 350
323 351 /**
@@ -523,13 +551,15 @@
523 551 'datePublished' => get_the_date('c', $post),
524 552 'dateModified' => get_the_modified_date('c', $post),
525 553 ];
526 554
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);
530 - if (!empty($excerpt)) {
531 - $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;
532 562 }
533 563
534 564 // Add author
535 565 $author_id = $post->post_author;
@@ -677,12 +707,13 @@
677 707 'datePublished' => get_the_date('c', $post),
678 708 'dateModified' => get_the_modified_date('c', $post),
679 709 ];
680 710
681 - // Add description
682 - $excerpt = $this->post_excerpt_text($post);
683 - if (!empty($excerpt)) {
684 - $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;
685 716 }
686 717
687 718 // Add featured image if available
688 719 if (has_post_thumbnail($post)) {
@@ -717,9 +748,9 @@
717 748 // Add caption/description
718 749 $caption = wp_get_attachment_caption($post->ID);
719 750 if (!empty($caption)) {
720 751 $schema['caption'] = $caption;
721 - $schema['description'] = $caption;
752 + $schema['description'] = self::normalize_description((string) $caption);
722 753 }
723 754
724 755 // Add dimensions
725 756 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
@@ -747,18 +778,18 @@
747 778 'name' => get_the_title($post),
748 779 'url' => get_permalink($post),
749 780 ];
750 781
751 - // Add description
752 - $description = $this->post_excerpt_text($post);
753 - if (empty($description)) {
754 - $caption = wp_get_attachment_caption($post->ID);
755 - if (!empty($caption)) {
756 - $description = $caption;
757 - }
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 + );
758 789 }
759 - if (!empty($description)) {
760 - $schema['description'] = wp_strip_all_tags($description);
790 + if ('' !== $description) {
791 + $schema['description'] = $description;
761 792 }
762 793
763 794 // For video attachments, add contentUrl
764 795 if ($post->post_type === 'attachment') {
@@ -914,13 +945,16 @@
914 945 private function get_product_description(\WP_Post $post): string {
915 946 // Try custom meta field first
916 947 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
917 948
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).
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).
921 955 if (empty($description)) {
922 - $description = $this->post_excerpt_text($post);
956 + $description = $this->schema_description($post);
923 957 }
924 958
925 959 if (empty($description)) {
926 960 $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
@@ -928,9 +962,14 @@
928 962 30
929 963 );
930 964 }
931 965
932 - 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);
933 972 }
934 973
935 974 /**
936 975 * Get product image
@@ -1212,8 +1251,129 @@
1212 1251
1213 1252 return '' !== $superseding
1214 1253 ? \ThinkRank\SEO\Pattern_Resolver::derive_excerpt($superseding, 30)
1215 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;
1216 1376 }
1217 1377
1218 1378 /**
1219 1379 * Register generated schema with the request's schema graph.