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 +206 -14 2.6.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
@@ -149,8 +167,16 @@
149 167 */
150 168 private static ?self $instance = null;
151 169
152 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 + /**
153 179 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
154 180 *
155 181 * @var array<int,array>
156 182 */
@@ -232,8 +258,10 @@
232 258 * @return void
233 259 */
234 260 public static function reset(): void {
235 261 self::$instance = null;
262 + // Or a test that seeds the switch inherits the previous test's answer.
263 + self::$master_switch_on = null;
236 264 }
237 265
238 266 /**
239 267 * Register a candidate for the page's single page-level entity.
@@ -302,8 +330,61 @@
302 330 return (is_string($actual) && $actual !== '') ? $actual : $declared;
303 331 }
304 332
305 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 + /**
306 387 * Register a node that does not compete for the page-level slot.
307 388 *
308 389 * @since 1.32.0
309 390 * @param array $schema Schema array.
@@ -560,11 +641,24 @@
560 641 if (!is_array($block)) {
561 642 continue;
562 643 }
563 644
564 - if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
565 - $attrs = $block['attrs'] ?? [];
645 + $block_name = (string) ($block['blockName'] ?? '');
646 + $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
566 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) {
567 661 // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
568 662 $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
569 663
570 664 if (!$disabled) {
@@ -788,8 +882,59 @@
788 882 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
789 883 }
790 884
791 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 + /**
792 937 * Assemble and emit the graph. Safe to call more than once.
793 938 *
794 939 * @since 1.32.0
795 940 * @return void
@@ -798,8 +943,14 @@
798 943 if ($this->rendered || !$this->has_nodes()) {
799 944 return;
800 945 }
801 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 +
802 953 // A 404 response represents no content, so there is nothing for
803 954 // structured data to describe. The page-level producers already skip
804 955 // this context, but the site-identity entity does not, so without this
805 956 // guard every miss — including crawlers probing URLs that never existed
@@ -834,8 +985,18 @@
834 985 if (empty($graph)) {
835 986 return;
836 987 }
837 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 +
838 999 $json = wp_json_encode(
839 1000 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
840 1001 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
841 1002 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -845,9 +1006,9 @@
845 1006 return;
846 1007 }
847 1008
848 1009 echo "<!-- ThinkRank Schema Graph -->\n";
849 - echo '<script type="application/ld+json">' . "\n";
1010 + echo '<script type="application/ld+json" data-thinkrank="schema-graph">' . "\n";
850 1011 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
851 1012 echo '</script>' . "\n";
852 1013 echo "<!-- /ThinkRank Schema Graph -->\n";
853 1014 }
@@ -958,11 +1119,12 @@
958 1119 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
959 1120 $faq = null;
960 1121 }
961 1122
962 - $nodes = [];
963 - $primary_id = '';
964 - $used_ids = [];
1123 + $nodes = [];
1124 + $primary_id = '';
1125 + $primary_type = '';
1126 + $used_ids = [];
965 1127
966 1128 if (null !== $primary) {
967 1129 $node = $primary['schema'];
968 1130
@@ -970,9 +1132,10 @@
970 1132 // so an "Article" setting that renders BlogPosting reads #blogposting.
971 1133 $resolved_type = $this->effective_type($node, $primary['type']);
972 1134
973 1135 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
974 - $primary_id = $node['@id'];
1136 + $primary_id = $node['@id'];
1137 + $primary_type = $resolved_type;
975 1138 $nodes['primary'] = $node;
976 1139 }
977 1140
978 1141 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -1013,8 +1176,9 @@
1013 1176 $person_nodes = [];
1014 1177
1015 1178 foreach ($this->supporting as $index => $node) {
1016 1179 $type = $node['@type'] ?? '';
1180 + $slot = $this->site_level_slot($type);
1017 1181
1018 1182 if ('BreadcrumbList' === $type) {
1019 1183 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
1020 1184 $breadcrumb_id = $node['@id'];
@@ -1023,9 +1187,9 @@
1023 1187 $website_id = $node['@id'];
1024 1188 } elseif ('Organization' === $type) {
1025 1189 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
1026 1190 $organization_nodes[] = $node;
1027 - } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
1191 + } elseif ('' !== $slot) {
1028 1192 // Site-level entities describe the site, not the page, so their
1029 1193 // @id must be stable across URLs. Falling through to the
1030 1194 // page-scoped branch minted a fresh identity on every URL, so
1031 1195 // one business became N entities in a crawler's graph and
@@ -1035,9 +1199,9 @@
1035 1199 // arrive here claiming the same @id. assign_id() would resolve
1036 1200 // that collision by minting "#person-2", turning a duplicate
1037 1201 // into two competing entities that split the identity a
1038 1202 // knowledge graph is meant to consolidate (#479).
1039 - $duplicate_key = $this->find_same_entity($nodes, $type, $node);
1203 + $duplicate_key = $this->find_same_entity($nodes, $slot, $node);
1040 1204
1041 1205 if (null !== $duplicate_key) {
1042 1206 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
1043 1207 continue;
@@ -1042,11 +1206,11 @@
1042 1206 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
1043 1207 continue;
1044 1208 }
1045 1209
1046 - $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
1210 + $node = $this->assign_id($node, home_url('/#' . strtolower($slot)), $used_ids);
1047 1211
1048 - if ('Person' === $type) {
1212 + if ('Person' === $slot) {
1049 1213 $person_nodes[] = $node;
1050 1214 }
1051 1215 } elseif (is_string($type) && $type !== '') {
1052 1216 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
@@ -1059,9 +1223,13 @@
1059 1223 if (isset($nodes['primary'])) {
1060 1224 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
1061 1225 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
1062 1226 }
1063 - 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 + ) {
1064 1232 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
1065 1233 }
1066 1234
1067 1235 // Point publisher/author at the full nodes already in the graph.
@@ -1135,8 +1303,32 @@
1135 1303 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
1136 1304 }
1137 1305
1138 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 + /**
1139 1331 * Find an already-placed node describing the same entity as $node.
1140 1332 *
1141 1333 * Identity is `email` when both carry one — two people can share a name,
1142 1334 * but not a mailbox — and a case-insensitive `name` match otherwise. A node
@@ -1145,9 +1337,9 @@
1145 1337 *
1146 1338 * @since 2.0.2
1147 1339 *
1148 1340 * @param array $nodes Nodes placed so far, keyed.
1149 - * @param string $type Schema type to match within.
1341 + * @param string $type Site-level slot to match within (see site_level_slot()).
1150 1342 * @param array $node Candidate node.
1151 1343 * @return string|null Key of the matching node, or null.
1152 1344 */
1153 1345 private function find_same_entity(array $nodes, string $type, array $node): ?string {
@@ -1158,9 +1350,9 @@
1158 1350 return null;
1159 1351 }
1160 1352
1161 1353 foreach ($nodes as $key => $placed) {
1162 - if (($placed['@type'] ?? '') !== $type) {
1354 + if ($this->site_level_slot($placed['@type'] ?? '') !== $type) {
1163 1355 continue;
1164 1356 }
1165 1357
1166 1358 $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';