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 +263 -26 2.0.2 → 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;
@@ -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 }
@@ -333,8 +392,27 @@
333 392 return !empty($settings['schema_type']);
334 393 }
335 394
336 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 + /**
337 415 * Get Global SEO settings for a specific post type
338 416 *
339 417 * @since 1.0.0
340 418 * @param string $post_type Post type
@@ -473,12 +551,15 @@
473 551 'datePublished' => get_the_date('c', $post),
474 552 'dateModified' => get_the_modified_date('c', $post),
475 553 ];
476 554
477 - // Add description
478 - $excerpt = get_the_excerpt($post);
479 - if (!empty($excerpt)) {
480 - $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;
481 562 }
482 563
483 564 // Add author
484 565 $author_id = $post->post_author;
@@ -547,9 +628,9 @@
547 628 }
548 629 require_once $builder_file;
549 630 }
550 631
551 - $excerpt = get_the_excerpt($post);
632 + $excerpt = $this->post_excerpt_text($post);
552 633
553 634 $builder = new \ThinkRank\SEO\Schema_Builder();
554 635 $schema = $builder->build_schema(
555 636 'FAQPage',
@@ -554,9 +635,9 @@
554 635 $schema = $builder->build_schema(
555 636 'FAQPage',
556 637 [
557 638 'title' => get_the_title($post),
558 - 'content' => $post->post_content,
639 + 'content' => \ThinkRank\SEO\Builder_Content::visible_content($post),
559 640 'excerpt' => $excerpt ? wp_strip_all_tags($excerpt) : '',
560 641 'url' => get_permalink($post),
561 642 ],
562 643 get_post_type($post) === 'page' ? 'page' : 'post'
@@ -626,12 +707,13 @@
626 707 'datePublished' => get_the_date('c', $post),
627 708 'dateModified' => get_the_modified_date('c', $post),
628 709 ];
629 710
630 - // Add description
631 - $excerpt = get_the_excerpt($post);
632 - if (!empty($excerpt)) {
633 - $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;
634 716 }
635 717
636 718 // Add featured image if available
637 719 if (has_post_thumbnail($post)) {
@@ -666,9 +748,9 @@
666 748 // Add caption/description
667 749 $caption = wp_get_attachment_caption($post->ID);
668 750 if (!empty($caption)) {
669 751 $schema['caption'] = $caption;
670 - $schema['description'] = $caption;
752 + $schema['description'] = self::normalize_description((string) $caption);
671 753 }
672 754
673 755 // Add dimensions
674 756 if (!empty($image_meta['width']) && !empty($image_meta['height'])) {
@@ -696,18 +778,18 @@
696 778 'name' => get_the_title($post),
697 779 'url' => get_permalink($post),
698 780 ];
699 781
700 - // Add description
701 - $description = get_the_excerpt($post);
702 - if (empty($description)) {
703 - $caption = wp_get_attachment_caption($post->ID);
704 - if (!empty($caption)) {
705 - $description = $caption;
706 - }
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 + );
707 789 }
708 - if (!empty($description)) {
709 - $schema['description'] = wp_strip_all_tags($description);
790 + if ('' !== $description) {
791 + $schema['description'] = $description;
710 792 }
711 793
712 794 // For video attachments, add contentUrl
713 795 if ($post->post_type === 'attachment') {
@@ -863,18 +945,31 @@
863 945 private function get_product_description(\WP_Post $post): string {
864 946 // Try custom meta field first
865 947 $description = get_post_meta($post->ID, '_thinkrank_product_description', true);
866 948
867 - // 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).
868 955 if (empty($description)) {
869 - $description = get_the_excerpt($post);
956 + $description = $this->schema_description($post);
870 957 }
871 958
872 959 if (empty($description)) {
873 - $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 + );
874 964 }
875 965
876 - 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);
877 972 }
878 973
879 974 /**
880 975 * Get product image
@@ -1135,8 +1230,150 @@
1135 1230 }
1136 1231 }
1137 1232
1138 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;
1139 1376 }
1140 1377
1141 1378 /**
1142 1379 * Register generated schema with the request's schema graph.