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 +220 -5 2.4.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
@@ -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`.
@@ -608,8 +700,58 @@
608 700 $this->walk_bricks($this->bricks_tree((int) $post->ID));
609 701 }
610 702
611 703 /**
704 + * Collect FAQ questions from Beaver Builder FAQ modules.
705 + *
706 + * Beaver Builder keeps its layout in postmeta as a map of node objects and
707 + * leaves `post_content` alone, so — unlike Bricks — there is no
708 + * "supersedes post_content" gate to apply: a block FAQ left in the body and
709 + * a module FAQ in the layout can both genuinely be on the page, and both
710 + * belong in the one FAQPage.
711 + *
712 + * The published layout is preferred over the draft for the same reason the
713 + * rest of the plugin prefers it: a draft holds edits no visitor has been
714 + * served yet, and schema must describe the page as delivered.
715 + *
716 + * @since 2.5.0
717 + * @param \WP_Post $post Post being viewed.
718 + * @return void
719 + */
720 + private function collect_beaver_faq(\WP_Post $post): void {
721 + $layout = get_post_meta($post->ID, '_fl_builder_data', true);
722 +
723 + if (!is_array($layout) || empty($layout)) {
724 + return;
725 + }
726 +
727 + foreach ($layout as $node) {
728 + $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
729 + $settings = is_object($settings) ? get_object_vars($settings) : $settings;
730 +
731 + if (!is_array($settings) || ($settings['type'] ?? '') !== self::FAQ_BEAVER_MODULE) {
732 + continue;
733 + }
734 +
735 + // Mirrors ThinkRank_Beaver_FAQ_Module::schema_enabled(): Beaver
736 + // Builder stores a cleared toggle as the string '0'.
737 + if (empty($settings['output_schema'])) {
738 + continue;
739 + }
740 +
741 + $rows = $settings['faqs'] ?? [];
742 + $rows = is_array($rows) ? array_map(
743 + static function ($row) {
744 + return is_object($row) ? get_object_vars($row) : $row;
745 + },
746 + $rows
747 + ) : [];
748 +
749 + $this->absorb_content_faq($this->questions_from_pairs($rows));
750 + }
751 + }
752 +
753 + /**
612 754 * Collect FAQ entries from a resolved Bricks tree.
613 755 *
614 756 * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
615 757 * splices component definitions into the same list.
@@ -727,8 +869,59 @@
727 869 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
728 870 }
729 871
730 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 + /**
731 924 * Assemble and emit the graph. Safe to call more than once.
732 925 *
733 926 * @since 1.32.0
734 927 * @return void
@@ -737,8 +930,14 @@
737 930 if ($this->rendered || !$this->has_nodes()) {
738 931 return;
739 932 }
740 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 +
741 940 // A 404 response represents no content, so there is nothing for
742 941 // structured data to describe. The page-level producers already skip
743 942 // this context, but the site-identity entity does not, so without this
744 943 // guard every miss — including crawlers probing URLs that never existed
@@ -773,8 +972,18 @@
773 972 if (empty($graph)) {
774 973 return;
775 974 }
776 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 +
777 986 $json = wp_json_encode(
778 987 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
779 988 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
780 989 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -897,11 +1106,12 @@
897 1106 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
898 1107 $faq = null;
899 1108 }
900 1109
901 - $nodes = [];
902 - $primary_id = '';
903 - $used_ids = [];
1110 + $nodes = [];
1111 + $primary_id = '';
1112 + $primary_type = '';
1113 + $used_ids = [];
904 1114
905 1115 if (null !== $primary) {
906 1116 $node = $primary['schema'];
907 1117
@@ -909,9 +1119,10 @@
909 1119 // so an "Article" setting that renders BlogPosting reads #blogposting.
910 1120 $resolved_type = $this->effective_type($node, $primary['type']);
911 1121
912 1122 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
913 - $primary_id = $node['@id'];
1123 + $primary_id = $node['@id'];
1124 + $primary_type = $resolved_type;
914 1125 $nodes['primary'] = $node;
915 1126 }
916 1127
917 1128 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -998,9 +1209,13 @@
998 1209 if (isset($nodes['primary'])) {
999 1210 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
1000 1211 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
1001 1212 }
1002 - 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 + ) {
1003 1218 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
1004 1219 }
1005 1220
1006 1221 // Point publisher/author at the full nodes already in the graph.