PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/frontend/class-schema-graph.php +159 -5 2.6.02.7.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.
@@ -788,8 +869,59 @@
788 869 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
789 870 }
790 871
791 872 /**
873 + * Whether ThinkRank may emit structured data for this request at all.
874 + *
875 + * The master switch and the matrix's per-content-type Schema switch, asked
876 + * once. #461 put the master switch inside output_site_schema_markup(),
877 + * which is one of four producers; the other three never learned about it,
878 + * so turning Schema off removed the deployed rows and left the live
879 + * generator running — the page emitted *more* types with the switch off
880 + * than with it on (#688).
881 + *
882 + * Static because the producers that need it do not share a base class: the
883 + * three graph producers converge on render(), but Blocks_Manager emits its
884 + * own script tag from a content filter and never touches the graph, so it
885 + * has to ask the same question independently.
886 + *
887 + * @since 2.7.0
888 + * @return bool True when structured data may be emitted.
889 + */
890 + public static function output_allowed(): bool {
891 + // Memoised because inject_block_schema() asks once per matching block,
892 + // and Schema_Management_System's constructor builds a schema builder and
893 + // a cache manager and registers listeners — it is not something to spin
894 + // up per block. The switch is site-wide, so it cannot change within a
895 + // request; the per-content-type check below is query-dependent and stays
896 + // live.
897 + if (null === self::$master_switch_on) {
898 + self::$master_switch_on = true;
899 +
900 + if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
901 + $settings = (new \ThinkRank\SEO\Schema_Management_System())->get_settings('site', null);
902 +
903 + // Absent means "not configured", which every other reader treats
904 + // as enabled; only a value that is present and off disables.
905 + self::$master_switch_on = !(array_key_exists('enabled', $settings) && empty($settings['enabled']));
906 + }
907 + }
908 +
909 + if (!self::$master_switch_on) {
910 + return false;
911 + }
912 +
913 + if (class_exists('ThinkRank\\SEO\\Content_Type_Settings')) {
914 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
915 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
916 + true
917 + );
918 + }
919 +
920 + return true;
921 + }
922 +
923 + /**
792 924 * Assemble and emit the graph. Safe to call more than once.
793 925 *
794 926 * @since 1.32.0
795 927 * @return void
@@ -798,8 +930,14 @@
798 930 if ($this->rendered || !$this->has_nodes()) {
799 931 return;
800 932 }
801 933
934 + // Every graph producer converges here, so this is the one place the
935 + // master switch has to hold for all of them (#688).
936 + if (!self::output_allowed()) {
937 + return;
938 + }
939 +
802 940 // A 404 response represents no content, so there is nothing for
803 941 // structured data to describe. The page-level producers already skip
804 942 // this context, but the site-identity entity does not, so without this
805 943 // guard every miss — including crawlers probing URLs that never existed
@@ -834,8 +972,18 @@
834 972 if (empty($graph)) {
835 973 return;
836 974 }
837 975
976 + // One pass over the assembled graph, rather than at each producer.
977 + // @id and url values arrive from a dozen of them — some derived from
978 + // WordPress, some read straight out of stored settings — and on a
979 + // misconfigured site that produced a single graph carrying both
980 + // schemes at once, with @ids that no longer matched the canonical they
981 + // are supposed to identify (#638). Normalizing where the graph is
982 + // serialized is the only place that catches all of them, including
983 + // nodes an add-on added through the filter above.
984 + $graph = \ThinkRank\SEO\Url_Scheme::apply_deep($graph);
985 +
838 986 $json = wp_json_encode(
839 987 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
840 988 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
841 989 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -958,11 +1106,12 @@
958 1106 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
959 1107 $faq = null;
960 1108 }
961 1109
962 - $nodes = [];
963 - $primary_id = '';
964 - $used_ids = [];
1110 + $nodes = [];
1111 + $primary_id = '';
1112 + $primary_type = '';
1113 + $used_ids = [];
965 1114
966 1115 if (null !== $primary) {
967 1116 $node = $primary['schema'];
968 1117
@@ -970,9 +1119,10 @@
970 1119 // so an "Article" setting that renders BlogPosting reads #blogposting.
971 1120 $resolved_type = $this->effective_type($node, $primary['type']);
972 1121
973 1122 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
974 - $primary_id = $node['@id'];
1123 + $primary_id = $node['@id'];
1124 + $primary_type = $resolved_type;
975 1125 $nodes['primary'] = $node;
976 1126 }
977 1127
978 1128 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -1059,9 +1209,13 @@
1059 1209 if (isset($nodes['primary'])) {
1060 1210 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
1061 1211 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
1062 1212 }
1063 - if ($breadcrumb_id !== '' && !isset($nodes['primary']['breadcrumb'])) {
1213 + if (
1214 + $breadcrumb_id !== ''
1215 + && !isset($nodes['primary']['breadcrumb'])
1216 + && $this->allows_breadcrumb($nodes['primary'], $primary_type)
1217 + ) {
1064 1218 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
1065 1219 }
1066 1220
1067 1221 // Point publisher/author at the full nodes already in the graph.