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 +473 -15 2.3.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
@@ -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,
@@ -500,11 +641,24 @@
500 641 if (!is_array($block)) {
501 642 continue;
502 643 }
503 644
504 - if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
505 - $attrs = $block['attrs'] ?? [];
645 + $block_name = (string) ($block['blockName'] ?? '');
646 + $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
506 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) {
507 661 // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
508 662 $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
509 663
510 664 if (!$disabled) {
@@ -539,8 +693,112 @@
539 693 $this->walk_elementor($elements);
540 694 }
541 695
542 696 /**
697 + * Collect FAQ questions from Bricks FAQ elements.
698 + *
699 + * Reads the tree Bricks will actually render — resolved through
700 + * `Builder_Content`, so a page whose content lives on a content template or
701 + * inside a component is covered, and one switched back to the block editor
702 + * is not.
703 + *
704 + * Unlike the block, this is not gated on Bricks owning `post_content`: a
705 + * Bricks element is on the page whenever Bricks renders the page, which is
706 + * exactly what resolving the tree already establishes (#626).
707 + *
708 + * @since 2.3.1
709 + * @param \WP_Post $post Post being viewed.
710 + * @return void
711 + */
712 + private function collect_bricks_faq(\WP_Post $post): void {
713 + $this->walk_bricks($this->bricks_tree((int) $post->ID));
714 + }
715 +
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 + /**
767 + * Collect FAQ entries from a resolved Bricks tree.
768 + *
769 + * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
770 + * splices component definitions into the same list.
771 + *
772 + * The element's own settings are read here rather than through
773 + * `FAQ_Element`, whose class extends `Bricks\Element` and so cannot even be
774 + * loaded when the theme is inactive — which is exactly the case that still
775 + * has a stored tree, on a site that has since switched themes. The repeater
776 + * uses the same `question` / `answer` keys as the block, so the shared
777 + * builder below already understands it.
778 + *
779 + * @since 2.3.1
780 + * @param array $elements Bricks elements.
781 + * @return void
782 + */
783 + private function walk_bricks(array $elements): void {
784 + foreach ($elements as $element) {
785 + if (!is_array($element) || ($element['name'] ?? '') !== self::FAQ_BRICKS_ELEMENT) {
786 + continue;
787 + }
788 +
789 + $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
790 +
791 + // Mirrors FAQ_Element: a cleared Bricks checkbox loses its key.
792 + if (empty($settings['outputSchema'])) {
793 + continue;
794 + }
795 +
796 + $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
797 + }
798 + }
799 +
800 + /**
543 801 * Recurse an Elementor element tree collecting FAQ entries.
544 802 *
545 803 * @since 1.32.0
546 804 * @param array $elements Elementor elements.
@@ -624,8 +882,59 @@
624 882 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
625 883 }
626 884
627 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 + /**
628 937 * Assemble and emit the graph. Safe to call more than once.
629 938 *
630 939 * @since 1.32.0
631 940 * @return void
@@ -634,8 +943,14 @@
634 943 if ($this->rendered || !$this->has_nodes()) {
635 944 return;
636 945 }
637 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 +
638 953 // A 404 response represents no content, so there is nothing for
639 954 // structured data to describe. The page-level producers already skip
640 955 // this context, but the site-identity entity does not, so without this
641 956 // guard every miss — including crawlers probing URLs that never existed
@@ -670,8 +985,18 @@
670 985 if (empty($graph)) {
671 986 return;
672 987 }
673 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 +
674 999 $json = wp_json_encode(
675 1000 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
676 1001 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
677 1002 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -681,9 +1006,9 @@
681 1006 return;
682 1007 }
683 1008
684 1009 echo "<!-- ThinkRank Schema Graph -->\n";
685 - echo '<script type="application/ld+json">' . "\n";
1010 + echo '<script type="application/ld+json" data-thinkrank="schema-graph">' . "\n";
686 1011 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
687 1012 echo '</script>' . "\n";
688 1013 echo "<!-- /ThinkRank Schema Graph -->\n";
689 1014 }
@@ -794,11 +1119,12 @@
794 1119 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
795 1120 $faq = null;
796 1121 }
797 1122
798 - $nodes = [];
799 - $primary_id = '';
800 - $used_ids = [];
1123 + $nodes = [];
1124 + $primary_id = '';
1125 + $primary_type = '';
1126 + $used_ids = [];
801 1127
802 1128 if (null !== $primary) {
803 1129 $node = $primary['schema'];
804 1130
@@ -806,9 +1132,10 @@
806 1132 // so an "Article" setting that renders BlogPosting reads #blogposting.
807 1133 $resolved_type = $this->effective_type($node, $primary['type']);
808 1134
809 1135 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
810 - $primary_id = $node['@id'];
1136 + $primary_id = $node['@id'];
1137 + $primary_type = $resolved_type;
811 1138 $nodes['primary'] = $node;
812 1139 }
813 1140
814 1141 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -849,8 +1176,9 @@
849 1176 $person_nodes = [];
850 1177
851 1178 foreach ($this->supporting as $index => $node) {
852 1179 $type = $node['@type'] ?? '';
1180 + $slot = $this->site_level_slot($type);
853 1181
854 1182 if ('BreadcrumbList' === $type) {
855 1183 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
856 1184 $breadcrumb_id = $node['@id'];
@@ -859,9 +1187,9 @@
859 1187 $website_id = $node['@id'];
860 1188 } elseif ('Organization' === $type) {
861 1189 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
862 1190 $organization_nodes[] = $node;
863 - } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
1191 + } elseif ('' !== $slot) {
864 1192 // Site-level entities describe the site, not the page, so their
865 1193 // @id must be stable across URLs. Falling through to the
866 1194 // page-scoped branch minted a fresh identity on every URL, so
867 1195 // one business became N entities in a crawler's graph and
@@ -871,9 +1199,9 @@
871 1199 // arrive here claiming the same @id. assign_id() would resolve
872 1200 // that collision by minting "#person-2", turning a duplicate
873 1201 // into two competing entities that split the identity a
874 1202 // knowledge graph is meant to consolidate (#479).
875 - $duplicate_key = $this->find_same_entity($nodes, $type, $node);
1203 + $duplicate_key = $this->find_same_entity($nodes, $slot, $node);
876 1204
877 1205 if (null !== $duplicate_key) {
878 1206 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
879 1207 continue;
@@ -878,11 +1206,11 @@
878 1206 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
879 1207 continue;
880 1208 }
881 1209
882 - $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
1210 + $node = $this->assign_id($node, home_url('/#' . strtolower($slot)), $used_ids);
883 1211
884 - if ('Person' === $type) {
1212 + if ('Person' === $slot) {
885 1213 $person_nodes[] = $node;
886 1214 }
887 1215 } elseif (is_string($type) && $type !== '') {
888 1216 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
@@ -895,9 +1223,13 @@
895 1223 if (isset($nodes['primary'])) {
896 1224 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
897 1225 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
898 1226 }
899 - 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 + ) {
900 1232 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
901 1233 }
902 1234
903 1235 // Point publisher/author at the full nodes already in the graph.
@@ -971,8 +1303,32 @@
971 1303 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
972 1304 }
973 1305
974 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 + /**
975 1331 * Find an already-placed node describing the same entity as $node.
976 1332 *
977 1333 * Identity is `email` when both carry one — two people can share a name,
978 1334 * but not a mailbox — and a case-insensitive `name` match otherwise. A node
@@ -981,9 +1337,9 @@
981 1337 *
982 1338 * @since 2.0.2
983 1339 *
984 1340 * @param array $nodes Nodes placed so far, keyed.
985 - * @param string $type Schema type to match within.
1341 + * @param string $type Site-level slot to match within (see site_level_slot()).
986 1342 * @param array $node Candidate node.
987 1343 * @return string|null Key of the matching node, or null.
988 1344 */
989 1345 private function find_same_entity(array $nodes, string $type, array $node): ?string {
@@ -994,9 +1350,9 @@
994 1350 return null;
995 1351 }
996 1352
997 1353 foreach ($nodes as $key => $placed) {
998 - if (($placed['@type'] ?? '') !== $type) {
1354 + if ($this->site_level_slot($placed['@type'] ?? '') !== $type) {
999 1355 continue;
1000 1356 }
1001 1357
1002 1358 $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';
@@ -1140,8 +1496,19 @@
1140 1496 if (!$post instanceof \WP_Post) {
1141 1497 return false;
1142 1498 }
1143 1499
1500 + return $this->has_foreign_elementor_faq($post) || $this->has_foreign_bricks_faq($post);
1501 + }
1502 +
1503 + /**
1504 + * Whether an Elementor widget on this post publishes a FAQPage.
1505 + *
1506 + * @since 2.1.0
1507 + * @param \WP_Post $post Post being viewed.
1508 + * @return bool
1509 + */
1510 + private function has_foreign_elementor_faq(\WP_Post $post): bool {
1144 1511 $raw = get_post_meta($post->ID, '_elementor_data', true);
1145 1512 if (empty($raw) || !is_string($raw)) {
1146 1513 return false;
1147 1514 }
@@ -1148,8 +1515,99 @@
1148 1515
1149 1516 $elements = json_decode($raw, true);
1150 1517
1151 1518 return is_array($elements) && $this->elements_have_foreign_faq($elements);
1519 + }
1520 +
1521 + /**
1522 + * Whether a Bricks element on this post publishes a FAQPage.
1523 + *
1524 + * Bricks' accordions emit their FAQPage from the body render, so — exactly
1525 + * as with EA's accordion — the stored tree is the only signal available at
1526 + * `wp_head`, where this decision has to be made.
1527 + *
1528 + * The tree comes from Builder_Content rather than a direct meta read: a
1529 + * Bricks page's content can live on a content template, be assembled from
1530 + * components, or be stored but not rendered because the post was switched
1531 + * back to the block editor. Reading the meta key here would get all three
1532 + * wrong (#649).
1533 + *
1534 + * @since 2.3.1
1535 + * @param \WP_Post $post Post being viewed.
1536 + * @return bool
1537 + */
1538 + private function has_foreign_bricks_faq(\WP_Post $post): bool {
1539 + foreach ($this->bricks_tree((int) $post->ID) as $element) {
1540 + if (is_array($element) && $this->bricks_element_publishes_faq($element)) {
1541 + return true;
1542 + }
1543 + }
1544 +
1545 + return false;
1546 + }
1547 +
1548 + /**
1549 + * Whether one Bricks element will put a FAQPage on the page.
1550 + *
1551 + * Mirrors Bricks' own emission condition rather than trusting the toggle:
1552 + * `accordion` records a question only for an item that has BOTH a title and
1553 + * content, so an armed but empty accordion publishes nothing and must not
1554 + * cost the page ThinkRank's FAQ node. `accordion-nested` builds its items
1555 + * from child elements instead of a repeater, so having children is the
1556 + * equivalent test there.
1557 + *
1558 + * @since 2.3.1
1559 + * @param array $element One Bricks element.
1560 + * @return bool
1561 + */
1562 + private function bricks_element_publishes_faq(array $element): bool {
1563 + $name = is_string($element['name'] ?? null) ? $element['name'] : '';
1564 + if (!in_array($name, self::FOREIGN_FAQ_BRICKS_ELEMENTS, true)) {
1565 + return false;
1566 + }
1567 +
1568 + $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1569 +
1570 + // Bricks writes a checkbox as `true`, and clears it by removing the key.
1571 + if (empty($settings['faqSchema'])) {
1572 + return false;
1573 + }
1574 +
1575 + if ('accordion-nested' === $name) {
1576 + return !empty($element['children']) && is_array($element['children']);
1577 + }
1578 +
1579 + $items = is_array($settings['accordions'] ?? null) ? $settings['accordions'] : [];
1580 +
1581 + foreach ($items as $item) {
1582 + if (is_array($item)
1583 + && '' !== trim((string) ($item['title'] ?? ''))
1584 + && '' !== trim((string) ($item['content'] ?? ''))
1585 + ) {
1586 + return true;
1587 + }
1588 + }
1589 +
1590 + return false;
1591 + }
1592 +
1593 + /**
1594 + * The Bricks element tree that renders for a post.
1595 + *
1596 + * @since 2.3.1
1597 + * @param int $post_id Post being viewed.
1598 + * @return array<int,mixed>
1599 + */
1600 + private function bricks_tree(int $post_id): array {
1601 + if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
1602 + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1603 + if (!file_exists($file)) {
1604 + return [];
1605 + }
1606 + require_once $file;
1607 + }
1608 +
1609 + return \ThinkRank\SEO\Builder_Content::bricks_tree($post_id);
1152 1610 }
1153 1611
1154 1612 /**
1155 1613 * Recurse an Elementor element tree looking for a third-party FAQ producer.