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 +217 -26 2.4.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 */
@@ -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;
@@ -291,10 +340,12 @@
291 340 'url' => home_url('/'),
292 341 ],
293 342 ];
294 343
295 - $description = trim(wp_strip_all_tags($description));
296 - 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) {
297 348 $schema['description'] = $description;
298 349 }
299 350
300 351 /**
@@ -327,8 +378,16 @@
327 378 * @param string $post_type Post type slug.
328 379 * @return bool True when structured data would be output for this post type.
329 380 */
330 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 +
331 390 $settings = $this->get_global_seo_settings($post_type);
332 391
333 392 return !empty($settings['schema_type']);
334 393 }
@@ -492,13 +551,15 @@
492 551 'datePublished' => get_the_date('c', $post),
493 552 'dateModified' => get_the_modified_date('c', $post),
494 553 ];
495 554
496 - // Add description. A Bricks page's stored `post_content` is not on the
497 - // page, so core's derived excerpt must not describe it (#651).
498 - $excerpt = $this->post_excerpt_text($post);
499 - if (!empty($excerpt)) {
500 - $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;
501 562 }
502 563
503 564 // Add author
504 565 $author_id = $post->post_author;
@@ -646,12 +707,13 @@
646 707 'datePublished' => get_the_date('c', $post),
647 708 'dateModified' => get_the_modified_date('c', $post),
648 709 ];
649 710
650 - // Add description
651 - $excerpt = $this->post_excerpt_text($post);
652 - if (!empty($excerpt)) {
653 - $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;
654 716 }
655 717
656 718 // Add featured image if available
657 719 if (has_post_thumbnail($post)) {
@@ -686,9 +748,9 @@
686 748 // Add caption/description
687 749 $caption = wp_get_attachment_caption($post->ID);
688 750 if (!empty($caption)) {
689 751 $schema['caption'] = $caption;
690 - $schema['description'] = $caption;
752 + $schema['description'] = self::normalize_description((string) $caption);
691 753 }
692 754
693 755 // Add dimensions
694 756 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
@@ -716,18 +778,18 @@
716 778 'name' => get_the_title($post),
717 779 'url' => get_permalink($post),
718 780 ];
719 781
720 - // Add description
721 - $description = $this->post_excerpt_text($post);
722 - if (empty($description)) {
723 - $caption = wp_get_attachment_caption($post->ID);
724 - if (!empty($caption)) {
725 - $description = $caption;
726 - }
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 + );
727 789 }
728 - if (!empty($description)) {
729 - $schema['description'] = wp_strip_all_tags($description);
790 + if ('' !== $description) {
791 + $schema['description'] = $description;
730 792 }
731 793
732 794 // For video attachments, add contentUrl
733 795 if ($post->post_type === 'attachment') {
@@ -883,13 +945,16 @@
883 945 private function get_product_description(\WP_Post $post): string {
884 946 // Try custom meta field first
885 947 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
886 948
887 - // Fallback to excerpt or content. On a Bricks page the excerpt core
888 - // derives comes from discarded `post_content`, so the visible body is
889 - // 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).
890 955 if (empty($description)) {
891 - $description = $this->post_excerpt_text($post);
956 + $description = $this->schema_description($post);
892 957 }
893 958
894 959 if (empty($description)) {
895 960 $description = \ThinkRank\SEO\Pattern_Resolver::derive_excerpt(
@@ -897,9 +962,14 @@
897 962 30
898 963 );
899 964 }
900 965
901 - 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);
902 972 }
903 973
904 974 /**
905 975 * Get product image
@@ -1181,8 +1251,129 @@
1181 1251
1182 1252 return '' !== $superseding
1183 1253 ? \ThinkRank\SEO\Pattern_Resolver::derive_excerpt($superseding, 30)
1184 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;
1185 1376 }
1186 1377
1187 1378 /**
1188 1379 * Register generated schema with the request's schema graph.