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 +426 -6 2.1.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
@@ -97,8 +115,25 @@
97 115 */
98 116 private const FAQ_WIDGET = 'thinkrank-faq';
99 117
100 118 /**
119 + * Bricks FAQ element name.
120 + *
121 + * @since 2.3.1
122 + */
123 + private const FAQ_BRICKS_ELEMENT = 'thinkrank-faq';
124 +
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 + /**
101 136 * Third-party Elementor widgets that publish their own FAQPage.
102 137 *
103 138 * Maps widgetType to the setting whose 'yes' arms that widget's FAQ schema,
104 139 * so an accordion used purely as an accordion never suppresses ours.
@@ -111,8 +146,22 @@
111 146 'eael-adv-accordion' => 'eael_adv_accordion_faq_schema_show',
112 147 ];
113 148
114 149 /**
150 + * Bricks elements that publish their own FAQPage.
151 + *
152 + * Bricks is a theme, not a plugin, and its accordions are core elements
153 + * rather than a third-party add-on — so unlike FOREIGN_FAQ_WIDGETS this is
154 + * a plain list: they share one gate, the `faqSchema` setting, and the
155 + * per-element part of the check is whether the element has usable items
156 + * (see bricks_element_publishes_faq()).
157 + *
158 + * @since 2.3.1
159 + * @var string[]
160 + */
161 + private const FOREIGN_FAQ_BRICKS_ELEMENTS = ['accordion', 'accordion-nested'];
162 +
163 + /**
115 164 * Singleton instance.
116 165 *
117 166 * @var self|null
118 167 */
@@ -118,8 +167,16 @@
118 167 */
119 168 private static ?self $instance = null;
120 169
121 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 + /**
122 179 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
123 180 *
124 181 * @var array<int,array>
125 182 */
@@ -201,8 +258,10 @@
201 258 * @return void
202 259 */
203 260 public static function reset(): void {
204 261 self::$instance = null;
262 + // Or a test that seeds the switch inherits the previous test's answer.
263 + self::$master_switch_on = null;
205 264 }
206 265
207 266 /**
208 267 * Register a candidate for the page's single page-level entity.
@@ -271,8 +330,61 @@
271 330 return (is_string($actual) && $actual !== '') ? $actual : $declared;
272 331 }
273 332
274 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 + /**
275 387 * Register a node that does not compete for the page-level slot.
276 388 *
277 389 * @since 1.32.0
278 390 * @param array $schema Schema array.
@@ -448,13 +560,42 @@
448 560 if (function_exists('post_password_required') && post_password_required($post)) {
449 561 return;
450 562 }
451 563
452 - $this->collect_block_faq($post);
564 + // The same gate, for the same reason, with a different cause: a Bricks
565 + // page throws `post_content` away, so a FAQ block left there when the
566 + // page was switched over never renders. Publishing its questions would
567 + // put schema on the page for content no visitor can see — which Google
568 + // treats as a violation, not merely a duplicate (#650).
569 + if (!$this->bricks_supersedes_post_content((int) $post->ID)) {
570 + $this->collect_block_faq($post);
571 + }
572 +
453 573 $this->collect_elementor_faq($post);
574 + $this->collect_bricks_faq($post);
575 + $this->collect_beaver_faq($post);
454 576 }
455 577
456 578 /**
579 + * Whether Bricks renders this post and discards its `post_content`.
580 + *
581 + * @since 2.3.1
582 + * @param int $post_id Post being viewed.
583 + * @return bool
584 + */
585 + private function bricks_supersedes_post_content(int $post_id): bool {
586 + if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
587 + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
588 + if (!file_exists($file)) {
589 + return false;
590 + }
591 + require_once $file;
592 + }
593 +
594 + return \ThinkRank\SEO\Builder_Content::bricks_supersedes_post_content($post_id);
595 + }
596 +
597 + /**
457 598 * Record that a body FAQ producer's content is represented in the graph.
458 599 *
459 600 * Deliberately not keyed on the entity count growing: when a block asks the
460 601 * same question as the per-post deployment, dedup means nothing is added,
@@ -539,8 +680,112 @@
539 680 $this->walk_elementor($elements);
540 681 }
541 682
542 683 /**
684 + * Collect FAQ questions from Bricks FAQ elements.
685 + *
686 + * Reads the tree Bricks will actually render — resolved through
687 + * `Builder_Content`, so a page whose content lives on a content template or
688 + * inside a component is covered, and one switched back to the block editor
689 + * is not.
690 + *
691 + * Unlike the block, this is not gated on Bricks owning `post_content`: a
692 + * Bricks element is on the page whenever Bricks renders the page, which is
693 + * exactly what resolving the tree already establishes (#626).
694 + *
695 + * @since 2.3.1
696 + * @param \WP_Post $post Post being viewed.
697 + * @return void
698 + */
699 + private function collect_bricks_faq(\WP_Post $post): void {
700 + $this->walk_bricks($this->bricks_tree((int) $post->ID));
701 + }
702 +
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 + /**
754 + * Collect FAQ entries from a resolved Bricks tree.
755 + *
756 + * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
757 + * splices component definitions into the same list.
758 + *
759 + * The element's own settings are read here rather than through
760 + * `FAQ_Element`, whose class extends `Bricks\Element` and so cannot even be
761 + * loaded when the theme is inactive — which is exactly the case that still
762 + * has a stored tree, on a site that has since switched themes. The repeater
763 + * uses the same `question` / `answer` keys as the block, so the shared
764 + * builder below already understands it.
765 + *
766 + * @since 2.3.1
767 + * @param array $elements Bricks elements.
768 + * @return void
769 + */
770 + private function walk_bricks(array $elements): void {
771 + foreach ($elements as $element) {
772 + if (!is_array($element) || ($element['name'] ?? '') !== self::FAQ_BRICKS_ELEMENT) {
773 + continue;
774 + }
775 +
776 + $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
777 +
778 + // Mirrors FAQ_Element: a cleared Bricks checkbox loses its key.
779 + if (empty($settings['outputSchema'])) {
780 + continue;
781 + }
782 +
783 + $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
784 + }
785 + }
786 +
787 + /**
543 788 * Recurse an Elementor element tree collecting FAQ entries.
544 789 *
545 790 * @since 1.32.0
546 791 * @param array $elements Elementor elements.
@@ -624,8 +869,59 @@
624 869 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
625 870 }
626 871
627 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 + /**
628 924 * Assemble and emit the graph. Safe to call more than once.
629 925 *
630 926 * @since 1.32.0
631 927 * @return void
@@ -634,8 +930,14 @@
634 930 if ($this->rendered || !$this->has_nodes()) {
635 931 return;
636 932 }
637 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 +
638 940 // A 404 response represents no content, so there is nothing for
639 941 // structured data to describe. The page-level producers already skip
640 942 // this context, but the site-identity entity does not, so without this
641 943 // guard every miss — including crawlers probing URLs that never existed
@@ -670,8 +972,18 @@
670 972 if (empty($graph)) {
671 973 return;
672 974 }
673 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 +
674 986 $json = wp_json_encode(
675 987 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
676 988 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
677 989 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -794,11 +1106,12 @@
794 1106 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
795 1107 $faq = null;
796 1108 }
797 1109
798 - $nodes = [];
799 - $primary_id = '';
800 - $used_ids = [];
1110 + $nodes = [];
1111 + $primary_id = '';
1112 + $primary_type = '';
1113 + $used_ids = [];
801 1114
802 1115 if (null !== $primary) {
803 1116 $node = $primary['schema'];
804 1117
@@ -806,9 +1119,10 @@
806 1119 // so an "Article" setting that renders BlogPosting reads #blogposting.
807 1120 $resolved_type = $this->effective_type($node, $primary['type']);
808 1121
809 1122 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
810 - $primary_id = $node['@id'];
1123 + $primary_id = $node['@id'];
1124 + $primary_type = $resolved_type;
811 1125 $nodes['primary'] = $node;
812 1126 }
813 1127
814 1128 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -895,9 +1209,13 @@
895 1209 if (isset($nodes['primary'])) {
896 1210 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
897 1211 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
898 1212 }
899 - 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 + ) {
900 1218 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
901 1219 }
902 1220
903 1221 // Point publisher/author at the full nodes already in the graph.
@@ -1140,8 +1458,19 @@
1140 1458 if (!$post instanceof \WP_Post) {
1141 1459 return false;
1142 1460 }
1143 1461
1462 + return $this->has_foreign_elementor_faq($post) || $this->has_foreign_bricks_faq($post);
1463 + }
1464 +
1465 + /**
1466 + * Whether an Elementor widget on this post publishes a FAQPage.
1467 + *
1468 + * @since 2.1.0
1469 + * @param \WP_Post $post Post being viewed.
1470 + * @return bool
1471 + */
1472 + private function has_foreign_elementor_faq(\WP_Post $post): bool {
1144 1473 $raw = get_post_meta($post->ID, '_elementor_data', true);
1145 1474 if (empty($raw) || !is_string($raw)) {
1146 1475 return false;
1147 1476 }
@@ -1148,8 +1477,99 @@
1148 1477
1149 1478 $elements = json_decode($raw, true);
1150 1479
1151 1480 return is_array($elements) && $this->elements_have_foreign_faq($elements);
1481 + }
1482 +
1483 + /**
1484 + * Whether a Bricks element on this post publishes a FAQPage.
1485 + *
1486 + * Bricks' accordions emit their FAQPage from the body render, so — exactly
1487 + * as with EA's accordion — the stored tree is the only signal available at
1488 + * `wp_head`, where this decision has to be made.
1489 + *
1490 + * The tree comes from Builder_Content rather than a direct meta read: a
1491 + * Bricks page's content can live on a content template, be assembled from
1492 + * components, or be stored but not rendered because the post was switched
1493 + * back to the block editor. Reading the meta key here would get all three
1494 + * wrong (#649).
1495 + *
1496 + * @since 2.3.1
1497 + * @param \WP_Post $post Post being viewed.
1498 + * @return bool
1499 + */
1500 + private function has_foreign_bricks_faq(\WP_Post $post): bool {
1501 + foreach ($this->bricks_tree((int) $post->ID) as $element) {
1502 + if (is_array($element) && $this->bricks_element_publishes_faq($element)) {
1503 + return true;
1504 + }
1505 + }
1506 +
1507 + return false;
1508 + }
1509 +
1510 + /**
1511 + * Whether one Bricks element will put a FAQPage on the page.
1512 + *
1513 + * Mirrors Bricks' own emission condition rather than trusting the toggle:
1514 + * `accordion` records a question only for an item that has BOTH a title and
1515 + * content, so an armed but empty accordion publishes nothing and must not
1516 + * cost the page ThinkRank's FAQ node. `accordion-nested` builds its items
1517 + * from child elements instead of a repeater, so having children is the
1518 + * equivalent test there.
1519 + *
1520 + * @since 2.3.1
1521 + * @param array $element One Bricks element.
1522 + * @return bool
1523 + */
1524 + private function bricks_element_publishes_faq(array $element): bool {
1525 + $name = is_string($element['name'] ?? null) ? $element['name'] : '';
1526 + if (!in_array($name, self::FOREIGN_FAQ_BRICKS_ELEMENTS, true)) {
1527 + return false;
1528 + }
1529 +
1530 + $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1531 +
1532 + // Bricks writes a checkbox as `true`, and clears it by removing the key.
1533 + if (empty($settings['faqSchema'])) {
1534 + return false;
1535 + }
1536 +
1537 + if ('accordion-nested' === $name) {
1538 + return !empty($element['children']) && is_array($element['children']);
1539 + }
1540 +
1541 + $items = is_array($settings['accordions'] ?? null) ? $settings['accordions'] : [];
1542 +
1543 + foreach ($items as $item) {
1544 + if (is_array($item)
1545 + && '' !== trim((string) ($item['title'] ?? ''))
1546 + && '' !== trim((string) ($item['content'] ?? ''))
1547 + ) {
1548 + return true;
1549 + }
1550 + }
1551 +
1552 + return false;
1553 + }
1554 +
1555 + /**
1556 + * The Bricks element tree that renders for a post.
1557 + *
1558 + * @since 2.3.1
1559 + * @param int $post_id Post being viewed.
1560 + * @return array<int,mixed>
1561 + */
1562 + private function bricks_tree(int $post_id): array {
1563 + if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
1564 + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1565 + if (!file_exists($file)) {
1566 + return [];
1567 + }
1568 + require_once $file;
1569 + }
1570 +
1571 + return \ThinkRank\SEO\Builder_Content::bricks_tree($post_id);
1152 1572 }
1153 1573
1154 1574 /**
1155 1575 * Recurse an Elementor element tree looking for a third-party FAQ producer.