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.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 1.0.1 All 51 releases
← All changes | includes/frontend/class-schema-graph.php +267 -14 2.4.0 → 2.10.0 View file →
@@ -87,8 +87,26 @@
87 87 'Book', 'Movie', 'Service', 'ImageObject', 'VideoObject',
88 88 ];
89 89
90 90 /**
91 + * Types that may carry a `breadcrumb` property.
92 + *
93 + * Schema.org limits `breadcrumb` to WebPage and its subtypes. Attaching it
94 + * to a BlogPosting, Product or Recipe primary fails validation with
95 + * "Unexpected property" on every URL; the standalone BreadcrumbList node is
96 + * emitted either way (#693).
97 + *
98 + * @since 2.7.0
99 + * @var array<int,string>
100 + */
101 + private const BREADCRUMB_TYPES = [
102 + 'WebPage', 'AboutPage', 'CheckoutPage', 'CollectionPage', 'ContactPage',
103 + 'FAQPage', 'ItemPage', 'MedicalWebPage', 'ProfilePage', 'QAPage',
104 + 'RealEstateListing', 'SearchResultsPage', 'MediaGallery', 'ImageGallery',
105 + 'VideoGallery',
106 + ];
107 +
108 + /**
91 109 * Gutenberg FAQ block name.
92 110 */
93 111 private const FAQ_BLOCK = 'thinkrank/faq';
94 112
@@ -104,8 +122,18 @@
104 122 */
105 123 private const FAQ_BRICKS_ELEMENT = 'thinkrank-faq';
106 124
107 125 /**
126 + * The Beaver Builder FAQ module's slug, as stored in its layout nodes.
127 + *
128 + * Matches `ThinkRank_Beaver_FAQ_Module::SLUG`. Duplicated as a literal
129 + * rather than referenced, because that class extends `FLBuilderModule` and
130 + * so cannot be loaded at all when Beaver Builder is inactive — which is
131 + * exactly the site that still has a stored layout, after a builder switch.
132 + */
133 + private const FAQ_BEAVER_MODULE = 'thinkrank-faq';
134 +
135 + /**
108 136 * Third-party Elementor widgets that publish their own FAQPage.
109 137 *
110 138 * Maps widgetType to the setting whose 'yes' arms that widget's FAQ schema,
111 139 * so an accordion used purely as an accordion never suppresses ours.
@@ -139,8 +167,16 @@
139 167 */
140 168 private static ?self $instance = null;
141 169
142 170 /**
171 + * Memoised master switch, or null when it has not been read this request.
172 + *
173 + * @since 2.7.0
174 + * @var bool|null
175 + */
176 + private static ?bool $master_switch_on = null;
177 +
178 + /**
143 179 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
144 180 *
145 181 * @var array<int,array>
146 182 */
@@ -222,8 +258,10 @@
222 258 * @return void
223 259 */
224 260 public static function reset(): void {
225 261 self::$instance = null;
262 + // Or a test that seeds the switch inherits the previous test's answer.
263 + self::$master_switch_on = null;
226 264 }
227 265
228 266 /**
229 267 * Register a candidate for the page's single page-level entity.
@@ -292,8 +330,61 @@
292 330 return (is_string($actual) && $actual !== '') ? $actual : $declared;
293 331 }
294 332
295 333 /**
334 + * Whether a page entity may carry a `breadcrumb` property.
335 + *
336 + * Reading the node's own `@type` key is not enough, and every case it
337 + * misses ends with a real WebPage losing a valid property:
338 + *
339 + * - A deployed schema whose stored JSON omits `@type` carries the type in
340 + * the `schema_type` column instead. `effective_type()` already resolves
341 + * that, which is why the node's `@id` reads `#webpage` even though the
342 + * node itself has no `@type` — so the resolved value is what has to be
343 + * consulted here too.
344 + * - JSON-LD permits several types on one node. `["WebPage", "FAQPage"]` is
345 + * a WebPage, but a strict in_array() against the array as a whole is
346 + * false, so the trail would be dropped from a page that may carry it.
347 + * - A list naming nothing usable (`[]`, `[null]`) is the first case wearing
348 + * the second's clothes, and resolves the same way.
349 + *
350 + * @since 2.7.0
351 + * @param array $node The page entity.
352 + * @param string $resolved_type Type the graph resolved for it.
353 + * @return bool
354 + */
355 + private function allows_breadcrumb(array $node, string $resolved_type): bool {
356 + $declared = $node['@type'] ?? '';
357 +
358 + // One path for both shapes. Splitting them invites the list branch to
359 + // grow its own idea of what an absent type means, which is the mistake
360 + // being corrected here in the first place.
361 + $named = false;
362 +
363 + foreach (is_array($declared) ? $declared : [$declared] as $type) {
364 + if (!is_string($type) || '' === $type) {
365 + continue;
366 + }
367 +
368 + $named = true;
369 +
370 + if (in_array($type, self::BREADCRUMB_TYPES, true)) {
371 + return true;
372 + }
373 + }
374 +
375 + // The node named a type, and none of them may carry a breadcrumb.
376 + if ($named) {
377 + return false;
378 + }
379 +
380 + // It named none, so it is whatever the graph resolved for it — the same
381 + // value its @id was minted from. An empty list is no more informative
382 + // than a missing key and must not read as "definitely not a WebPage".
383 + return in_array($resolved_type, self::BREADCRUMB_TYPES, true);
384 + }
385 +
386 + /**
296 387 * Register a node that does not compete for the page-level slot.
297 388 *
298 389 * @since 1.32.0
299 390 * @param array $schema Schema array.
@@ -480,8 +571,9 @@
480 571 }
481 572
482 573 $this->collect_elementor_faq($post);
483 574 $this->collect_bricks_faq($post);
575 + $this->collect_beaver_faq($post);
484 576 }
485 577
486 578 /**
487 579 * Whether Bricks renders this post and discards its `post_content`.
@@ -549,11 +641,24 @@
549 641 if (!is_array($block)) {
550 642 continue;
551 643 }
552 644
553 - if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
554 - $attrs = $block['attrs'] ?? [];
645 + $block_name = (string) ($block['blockName'] ?? '');
646 + $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
555 647
648 + // A leftover Rank Math FAQ block is absorbed as if it were ours, so
649 + // an unmigrated post contributes its questions to the single
650 + // FAQPage rather than to nothing at all (#777). The fallback stays
651 + // silent while Rank Math is active and still emitting its own.
652 + if (\ThinkRank\Integrations\Rank_Math_Blocks::is_source_block($block_name)) {
653 + $fallback = \ThinkRank\Integrations\Rank_Math_Blocks::schema_fallback($block_name, $attrs);
654 + if (null !== $fallback) {
655 + $block_name = $fallback['name'];
656 + $attrs = $fallback['attrs'];
657 + }
658 + }
659 +
660 + if ($block_name === self::FAQ_BLOCK) {
556 661 // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
557 662 $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
558 663
559 664 if (!$disabled) {
@@ -608,8 +713,58 @@
608 713 $this->walk_bricks($this->bricks_tree((int) $post->ID));
609 714 }
610 715
611 716 /**
717 + * Collect FAQ questions from Beaver Builder FAQ modules.
718 + *
719 + * Beaver Builder keeps its layout in postmeta as a map of node objects and
720 + * leaves `post_content` alone, so — unlike Bricks — there is no
721 + * "supersedes post_content" gate to apply: a block FAQ left in the body and
722 + * a module FAQ in the layout can both genuinely be on the page, and both
723 + * belong in the one FAQPage.
724 + *
725 + * The published layout is preferred over the draft for the same reason the
726 + * rest of the plugin prefers it: a draft holds edits no visitor has been
727 + * served yet, and schema must describe the page as delivered.
728 + *
729 + * @since 2.5.0
730 + * @param \WP_Post $post Post being viewed.
731 + * @return void
732 + */
733 + private function collect_beaver_faq(\WP_Post $post): void {
734 + $layout = get_post_meta($post->ID, '_fl_builder_data', true);
735 +
736 + if (!is_array($layout) || empty($layout)) {
737 + return;
738 + }
739 +
740 + foreach ($layout as $node) {
741 + $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
742 + $settings = is_object($settings) ? get_object_vars($settings) : $settings;
743 +
744 + if (!is_array($settings) || ($settings['type'] ?? '') !== self::FAQ_BEAVER_MODULE) {
745 + continue;
746 + }
747 +
748 + // Mirrors ThinkRank_Beaver_FAQ_Module::schema_enabled(): Beaver
749 + // Builder stores a cleared toggle as the string '0'.
750 + if (empty($settings['output_schema'])) {
751 + continue;
752 + }
753 +
754 + $rows = $settings['faqs'] ?? [];
755 + $rows = is_array($rows) ? array_map(
756 + static function ($row) {
757 + return is_object($row) ? get_object_vars($row) : $row;
758 + },
759 + $rows
760 + ) : [];
761 +
762 + $this->absorb_content_faq($this->questions_from_pairs($rows));
763 + }
764 + }
765 +
766 + /**
612 767 * Collect FAQ entries from a resolved Bricks tree.
613 768 *
614 769 * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
615 770 * splices component definitions into the same list.
@@ -727,8 +882,59 @@
727 882 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
728 883 }
729 884
730 885 /**
886 + * Whether ThinkRank may emit structured data for this request at all.
887 + *
888 + * The master switch and the matrix's per-content-type Schema switch, asked
889 + * once. #461 put the master switch inside output_site_schema_markup(),
890 + * which is one of four producers; the other three never learned about it,
891 + * so turning Schema off removed the deployed rows and left the live
892 + * generator running — the page emitted *more* types with the switch off
893 + * than with it on (#688).
894 + *
895 + * Static because the producers that need it do not share a base class: the
896 + * three graph producers converge on render(), but Blocks_Manager emits its
897 + * own script tag from a content filter and never touches the graph, so it
898 + * has to ask the same question independently.
899 + *
900 + * @since 2.7.0
901 + * @return bool True when structured data may be emitted.
902 + */
903 + public static function output_allowed(): bool {
904 + // Memoised because inject_block_schema() asks once per matching block,
905 + // and Schema_Management_System's constructor builds a schema builder and
906 + // a cache manager and registers listeners — it is not something to spin
907 + // up per block. The switch is site-wide, so it cannot change within a
908 + // request; the per-content-type check below is query-dependent and stays
909 + // live.
910 + if (null === self::$master_switch_on) {
911 + self::$master_switch_on = true;
912 +
913 + if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
914 + $settings = (new \ThinkRank\SEO\Schema_Management_System())->get_settings('site', null);
915 +
916 + // Absent means "not configured", which every other reader treats
917 + // as enabled; only a value that is present and off disables.
918 + self::$master_switch_on = !(array_key_exists('enabled', $settings) && empty($settings['enabled']));
919 + }
920 + }
921 +
922 + if (!self::$master_switch_on) {
923 + return false;
924 + }
925 +
926 + if (class_exists('ThinkRank\\SEO\\Content_Type_Settings')) {
927 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
928 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
929 + true
930 + );
931 + }
932 +
933 + return true;
934 + }
935 +
936 + /**
731 937 * Assemble and emit the graph. Safe to call more than once.
732 938 *
733 939 * @since 1.32.0
734 940 * @return void
@@ -737,8 +943,14 @@
737 943 if ($this->rendered || !$this->has_nodes()) {
738 944 return;
739 945 }
740 946
947 + // Every graph producer converges here, so this is the one place the
948 + // master switch has to hold for all of them (#688).
949 + if (!self::output_allowed()) {
950 + return;
951 + }
952 +
741 953 // A 404 response represents no content, so there is nothing for
742 954 // structured data to describe. The page-level producers already skip
743 955 // this context, but the site-identity entity does not, so without this
744 956 // guard every miss — including crawlers probing URLs that never existed
@@ -773,8 +985,18 @@
773 985 if (empty($graph)) {
774 986 return;
775 987 }
776 988
989 + // One pass over the assembled graph, rather than at each producer.
990 + // @id and url values arrive from a dozen of them — some derived from
991 + // WordPress, some read straight out of stored settings — and on a
992 + // misconfigured site that produced a single graph carrying both
993 + // schemes at once, with @ids that no longer matched the canonical they
994 + // are supposed to identify (#638). Normalizing where the graph is
995 + // serialized is the only place that catches all of them, including
996 + // nodes an add-on added through the filter above.
997 + $graph = \ThinkRank\SEO\Url_Scheme::apply_deep($graph);
998 +
777 999 $json = wp_json_encode(
778 1000 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
779 1001 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
780 1002 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -784,9 +1006,9 @@
784 1006 return;
785 1007 }
786 1008
787 1009 echo "<!-- ThinkRank Schema Graph -->\n";
788 - echo '<script type="application/ld+json">' . "\n";
1010 + echo '<script type="application/ld+json" data-thinkrank="schema-graph">' . "\n";
789 1011 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
790 1012 echo '</script>' . "\n";
791 1013 echo "<!-- /ThinkRank Schema Graph -->\n";
792 1014 }
@@ -897,11 +1119,12 @@
897 1119 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
898 1120 $faq = null;
899 1121 }
900 1122
901 - $nodes = [];
902 - $primary_id = '';
903 - $used_ids = [];
1123 + $nodes = [];
1124 + $primary_id = '';
1125 + $primary_type = '';
1126 + $used_ids = [];
904 1127
905 1128 if (null !== $primary) {
906 1129 $node = $primary['schema'];
907 1130
@@ -909,9 +1132,10 @@
909 1132 // so an "Article" setting that renders BlogPosting reads #blogposting.
910 1133 $resolved_type = $this->effective_type($node, $primary['type']);
911 1134
912 1135 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
913 - $primary_id = $node['@id'];
1136 + $primary_id = $node['@id'];
1137 + $primary_type = $resolved_type;
914 1138 $nodes['primary'] = $node;
915 1139 }
916 1140
917 1141 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -952,8 +1176,9 @@
952 1176 $person_nodes = [];
953 1177
954 1178 foreach ($this->supporting as $index => $node) {
955 1179 $type = $node['@type'] ?? '';
1180 + $slot = $this->site_level_slot($type);
956 1181
957 1182 if ('BreadcrumbList' === $type) {
958 1183 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
959 1184 $breadcrumb_id = $node['@id'];
@@ -962,9 +1187,9 @@
962 1187 $website_id = $node['@id'];
963 1188 } elseif ('Organization' === $type) {
964 1189 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
965 1190 $organization_nodes[] = $node;
966 - } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
1191 + } elseif ('' !== $slot) {
967 1192 // Site-level entities describe the site, not the page, so their
968 1193 // @id must be stable across URLs. Falling through to the
969 1194 // page-scoped branch minted a fresh identity on every URL, so
970 1195 // one business became N entities in a crawler's graph and
@@ -974,9 +1199,9 @@
974 1199 // arrive here claiming the same @id. assign_id() would resolve
975 1200 // that collision by minting "#person-2", turning a duplicate
976 1201 // into two competing entities that split the identity a
977 1202 // knowledge graph is meant to consolidate (#479).
978 - $duplicate_key = $this->find_same_entity($nodes, $type, $node);
1203 + $duplicate_key = $this->find_same_entity($nodes, $slot, $node);
979 1204
980 1205 if (null !== $duplicate_key) {
981 1206 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
982 1207 continue;
@@ -981,11 +1206,11 @@
981 1206 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
982 1207 continue;
983 1208 }
984 1209
985 - $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
1210 + $node = $this->assign_id($node, home_url('/#' . strtolower($slot)), $used_ids);
986 1211
987 - if ('Person' === $type) {
1212 + if ('Person' === $slot) {
988 1213 $person_nodes[] = $node;
989 1214 }
990 1215 } elseif (is_string($type) && $type !== '') {
991 1216 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
@@ -998,9 +1223,13 @@
998 1223 if (isset($nodes['primary'])) {
999 1224 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
1000 1225 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
1001 1226 }
1002 - if ($breadcrumb_id !== '' && !isset($nodes['primary']['breadcrumb'])) {
1227 + if (
1228 + $breadcrumb_id !== ''
1229 + && !isset($nodes['primary']['breadcrumb'])
1230 + && $this->allows_breadcrumb($nodes['primary'], $primary_type)
1231 + ) {
1003 1232 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
1004 1233 }
1005 1234
1006 1235 // Point publisher/author at the full nodes already in the graph.
@@ -1074,8 +1303,32 @@
1074 1303 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
1075 1304 }
1076 1305
1077 1306 /**
1307 + * The site-level entity a node's @type makes it, or '' for none.
1308 + *
1309 + * A LocalBusiness is published under the subtype the site chose in Local
1310 + * SEO ("Dentist", "Restaurant"), but it is still the one business entity,
1311 + * so every subtype shares the LocalBusiness slot: the same home-scoped
1312 + * `/#localbusiness` @id it always had, and the same entity for
1313 + * find_same_entity() to fold a per-post copy into. Matching the literal
1314 + * type instead would have given a Dentist a fresh page-scoped @id on every
1315 + * URL, the exact split #471 closed.
1316 + *
1317 + * @since 2.10.0
1318 + *
1319 + * @param mixed $type Node @type.
1320 + * @return string 'LocalBusiness', 'Person' or ''.
1321 + */
1322 + private function site_level_slot($type): string {
1323 + if (\ThinkRank\Config\Local_Business_Types_Config::is_local_business($type)) {
1324 + return 'LocalBusiness';
1325 + }
1326 +
1327 + return (is_string($type) && in_array($type, self::SITE_LEVEL_TYPES, true)) ? $type : '';
1328 + }
1329 +
1330 + /**
1078 1331 * Find an already-placed node describing the same entity as $node.
1079 1332 *
1080 1333 * Identity is `email` when both carry one — two people can share a name,
1081 1334 * but not a mailbox — and a case-insensitive `name` match otherwise. A node
@@ -1084,9 +1337,9 @@
1084 1337 *
1085 1338 * @since 2.0.2
1086 1339 *
1087 1340 * @param array $nodes Nodes placed so far, keyed.
1088 - * @param string $type Schema type to match within.
1341 + * @param string $type Site-level slot to match within (see site_level_slot()).
1089 1342 * @param array $node Candidate node.
1090 1343 * @return string|null Key of the matching node, or null.
1091 1344 */
1092 1345 private function find_same_entity(array $nodes, string $type, array $node): ?string {
@@ -1097,9 +1350,9 @@
1097 1350 return null;
1098 1351 }
1099 1352
1100 1353 foreach ($nodes as $key => $placed) {
1101 - if (($placed['@type'] ?? '') !== $type) {
1354 + if ($this->site_level_slot($placed['@type'] ?? '') !== $type) {
1102 1355 continue;
1103 1356 }
1104 1357
1105 1358 $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';