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.11.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 All 52 releases
← All changes | includes/frontend/class-schema-graph.php +628 -25 2.0.2 → 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,53 @@
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 + /**
136 + * Third-party Elementor widgets that publish their own FAQPage.
137 + *
138 + * Maps widgetType to the setting whose 'yes' arms that widget's FAQ schema,
139 + * so an accordion used purely as an accordion never suppresses ours.
140 + *
141 + * @since 2.1.0
142 + * @var array<string,string>
143 + */
144 + private const FOREIGN_FAQ_WIDGETS = [
145 + // Essential Addons for Elementor — Advanced Accordion.
146 + 'eael-adv-accordion' => 'eael_adv_accordion_faq_schema_show',
147 + ];
148 +
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 + /**
101 164 * Singleton instance.
102 165 *
103 166 * @var self|null
104 167 */
@@ -104,8 +167,16 @@
104 167 */
105 168 private static ?self $instance = null;
106 169
107 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 + /**
108 179 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
109 180 *
110 181 * @var array<int,array>
111 182 */
@@ -125,8 +196,16 @@
125 196 */
126 197 private array $faq_entities = [];
127 198
128 199 /**
200 + * Memoized answer to "should this request emit a FAQPage at all?".
201 + *
202 + * @since 2.1.0
203 + * @var bool|null
204 + */
205 + private ?bool $emit_faqpage = null;
206 +
207 + /**
129 208 * Whether FAQ content was taken from the rendered post body (block/widget),
130 209 * meaning those producers must not emit their own duplicate script.
131 210 *
132 211 * @var bool
@@ -179,8 +258,10 @@
179 258 * @return void
180 259 */
181 260 public static function reset(): void {
182 261 self::$instance = null;
262 + // Or a test that seeds the switch inherits the previous test's answer.
263 + self::$master_switch_on = null;
183 264 }
184 265
185 266 /**
186 267 * Register a candidate for the page's single page-level entity.
@@ -201,13 +282,22 @@
201 282 }
202 283
203 284 $type = $this->effective_type($schema, $type);
204 285
205 - if ('FAQPage' === $type) {
286 + if ('FAQPage' === $type && $this->should_emit_faqpage()) {
206 287 $this->add_faq_entities($schema['mainEntity'] ?? []);
207 288 return;
208 289 }
209 290
291 + // A third party owns the page's FAQPage, so ours must not be emitted
292 + // (#494). Demote rather than drop: a FAQPage is still the page, and
293 + // returning here would leave the URL with no page-level entity at all.
294 + if ('FAQPage' === $type) {
295 + $schema['@type'] = 'WebPage';
296 + unset($schema['mainEntity']);
297 + $type = 'WebPage';
298 + }
299 +
210 300 // A per-post deployment can be something that isn't what the page is
211 301 // about (an Organization, say). Letting it win the slot would drop the
212 302 // page's real entity, so it joins the graph as a supporting node.
213 303 if (!in_array($type, self::PAGE_LEVEL_TYPES, true)) {
@@ -240,8 +330,61 @@
240 330 return (is_string($actual) && $actual !== '') ? $actual : $declared;
241 331 }
242 332
243 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 + /**
244 387 * Register a node that does not compete for the page-level slot.
245 388 *
246 389 * @since 1.32.0
247 390 * @param array $schema Schema array.
@@ -254,10 +397,17 @@
254 397 }
255 398
256 399 $effective_type = $this->effective_type($schema, $type);
257 400
401 + // A supporting FAQPage never survives as its own node: its questions
402 + // merge into the graph's single FAQ node, or are dropped when a third
403 + // party already owns the page's FAQPage (#494). Unlike the primary
404 + // slot there is nothing to preserve here, so demotion would only add a
405 + // second page-level entity beside the real one.
258 406 if ('FAQPage' === $effective_type) {
259 - $this->add_faq_entities($schema['mainEntity'] ?? []);
407 + if ($this->should_emit_faqpage()) {
408 + $this->add_faq_entities($schema['mainEntity'] ?? []);
409 + }
260 410 return;
261 411 }
262 412
263 413 // One breadcrumb trail per page. A deployed BreadcrumbList lands here
@@ -410,13 +560,42 @@
410 560 if (function_exists('post_password_required') && post_password_required($post)) {
411 561 return;
412 562 }
413 563
414 - $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 +
415 573 $this->collect_elementor_faq($post);
574 + $this->collect_bricks_faq($post);
575 + $this->collect_beaver_faq($post);
416 576 }
417 577
418 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 + /**
419 598 * Record that a body FAQ producer's content is represented in the graph.
420 599 *
421 600 * Deliberately not keyed on the entity count growing: when a block asks the
422 601 * same question as the per-post deployment, dedup means nothing is added,
@@ -462,11 +641,24 @@
462 641 if (!is_array($block)) {
463 642 continue;
464 643 }
465 644
466 - if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
467 - $attrs = $block['attrs'] ?? [];
645 + $block_name = (string) ($block['blockName'] ?? '');
646 + $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
468 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) {
469 661 // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
470 662 $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
471 663
472 664 if (!$disabled) {
@@ -501,8 +693,112 @@
501 693 $this->walk_elementor($elements);
502 694 }
503 695
504 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 + /**
505 801 * Recurse an Elementor element tree collecting FAQ entries.
506 802 *
507 803 * @since 1.32.0
508 804 * @param array $elements Elementor elements.
@@ -556,15 +852,13 @@
556 852 }
557 853
558 854 $text = wp_kses_post($answer);
559 855
560 - // Mirrors Blocks_Manager::build_faq_schema(): a per-item image is
561 - // carried inside the answer HTML (Yoast-style).
562 - $image_url = isset($pair['imageUrl']) ? esc_url((string) $pair['imageUrl']) : '';
563 - if ($image_url !== '') {
564 - $image_alt = isset($pair['imageAlt']) ? esc_attr((string) $pair['imageAlt']) : '';
565 - $text .= ' <img src="' . $image_url . '" alt="' . $image_alt . '" />';
566 - }
856 + // Mirrors Blocks_Manager::build_faq_schema() by calling the same
857 + // builder, so the two paths cannot drift — the per-item image is
858 + // resolved from its attachment id, carries intrinsic dimensions,
859 + // and disappears if the media was deleted (#418).
860 + $text .= \ThinkRank\Editor\Blocks_Manager::faq_image_markup(is_array($pair) ? $pair : []);
567 861
568 862 $entities[] = [
569 863 '@type' => 'Question',
570 864 'name' => $question,
@@ -588,8 +882,59 @@
588 882 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
589 883 }
590 884
591 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 + /**
592 937 * Assemble and emit the graph. Safe to call more than once.
593 938 *
594 939 * @since 1.32.0
595 940 * @return void
@@ -598,8 +943,14 @@
598 943 if ($this->rendered || !$this->has_nodes()) {
599 944 return;
600 945 }
601 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 +
602 953 // A 404 response represents no content, so there is nothing for
603 954 // structured data to describe. The page-level producers already skip
604 955 // this context, but the site-identity entity does not, so without this
605 956 // guard every miss — including crawlers probing URLs that never existed
@@ -634,8 +985,18 @@
634 985 if (empty($graph)) {
635 986 return;
636 987 }
637 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 +
638 999 $json = wp_json_encode(
639 1000 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
640 1001 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
641 1002 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -645,9 +1006,9 @@
645 1006 return;
646 1007 }
647 1008
648 1009 echo "<!-- ThinkRank Schema Graph -->\n";
649 - echo '<script type="application/ld+json">' . "\n";
1010 + echo '<script type="application/ld+json" data-thinkrank="schema-graph">' . "\n";
650 1011 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
651 1012 echo '</script>' . "\n";
652 1013 echo "<!-- /ThinkRank Schema Graph -->\n";
653 1014 }
@@ -758,11 +1119,12 @@
758 1119 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
759 1120 $faq = null;
760 1121 }
761 1122
762 - $nodes = [];
763 - $primary_id = '';
764 - $used_ids = [];
1123 + $nodes = [];
1124 + $primary_id = '';
1125 + $primary_type = '';
1126 + $used_ids = [];
765 1127
766 1128 if (null !== $primary) {
767 1129 $node = $primary['schema'];
768 1130
@@ -770,9 +1132,10 @@
770 1132 // so an "Article" setting that renders BlogPosting reads #blogposting.
771 1133 $resolved_type = $this->effective_type($node, $primary['type']);
772 1134
773 1135 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
774 - $primary_id = $node['@id'];
1136 + $primary_id = $node['@id'];
1137 + $primary_type = $resolved_type;
775 1138 $nodes['primary'] = $node;
776 1139 }
777 1140
778 1141 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -813,8 +1176,9 @@
813 1176 $person_nodes = [];
814 1177
815 1178 foreach ($this->supporting as $index => $node) {
816 1179 $type = $node['@type'] ?? '';
1180 + $slot = $this->site_level_slot($type);
817 1181
818 1182 if ('BreadcrumbList' === $type) {
819 1183 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
820 1184 $breadcrumb_id = $node['@id'];
@@ -823,9 +1187,9 @@
823 1187 $website_id = $node['@id'];
824 1188 } elseif ('Organization' === $type) {
825 1189 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
826 1190 $organization_nodes[] = $node;
827 - } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
1191 + } elseif ('' !== $slot) {
828 1192 // Site-level entities describe the site, not the page, so their
829 1193 // @id must be stable across URLs. Falling through to the
830 1194 // page-scoped branch minted a fresh identity on every URL, so
831 1195 // one business became N entities in a crawler's graph and
@@ -835,9 +1199,9 @@
835 1199 // arrive here claiming the same @id. assign_id() would resolve
836 1200 // that collision by minting "#person-2", turning a duplicate
837 1201 // into two competing entities that split the identity a
838 1202 // knowledge graph is meant to consolidate (#479).
839 - $duplicate_key = $this->find_same_entity($nodes, $type, $node);
1203 + $duplicate_key = $this->find_same_entity($nodes, $slot, $node);
840 1204
841 1205 if (null !== $duplicate_key) {
842 1206 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
843 1207 continue;
@@ -842,11 +1206,11 @@
842 1206 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
843 1207 continue;
844 1208 }
845 1209
846 - $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
1210 + $node = $this->assign_id($node, home_url('/#' . strtolower($slot)), $used_ids);
847 1211
848 - if ('Person' === $type) {
1212 + if ('Person' === $slot) {
849 1213 $person_nodes[] = $node;
850 1214 }
851 1215 } elseif (is_string($type) && $type !== '') {
852 1216 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
@@ -859,9 +1223,13 @@
859 1223 if (isset($nodes['primary'])) {
860 1224 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
861 1225 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
862 1226 }
863 - 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 + ) {
864 1232 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
865 1233 }
866 1234
867 1235 // Point publisher/author at the full nodes already in the graph.
@@ -935,8 +1303,32 @@
935 1303 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
936 1304 }
937 1305
938 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 + /**
939 1331 * Find an already-placed node describing the same entity as $node.
940 1332 *
941 1333 * Identity is `email` when both carry one — two people can share a name,
942 1334 * but not a mailbox — and a case-insensitive `name` match otherwise. A node
@@ -945,9 +1337,9 @@
945 1337 *
946 1338 * @since 2.0.2
947 1339 *
948 1340 * @param array $nodes Nodes placed so far, keyed.
949 - * @param string $type Schema type to match within.
1341 + * @param string $type Site-level slot to match within (see site_level_slot()).
950 1342 * @param array $node Candidate node.
951 1343 * @return string|null Key of the matching node, or null.
952 1344 */
953 1345 private function find_same_entity(array $nodes, string $type, array $node): ?string {
@@ -958,9 +1350,9 @@
958 1350 return null;
959 1351 }
960 1352
961 1353 foreach ($nodes as $key => $placed) {
962 - if (($placed['@type'] ?? '') !== $type) {
1354 + if ($this->site_level_slot($placed['@type'] ?? '') !== $type) {
963 1355 continue;
964 1356 }
965 1357
966 1358 $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';
@@ -1043,15 +1435,226 @@
1043 1435 return $node;
1044 1436 }
1045 1437
1046 1438 /**
1439 + * Whether ThinkRank should emit a FAQPage on this request.
1440 + *
1441 + * ThinkRank emitted its FAQPage unconditionally, so a URL whose FAQ was
1442 + * already published by another plugin carried two FAQPage entities — each
1443 + * valid on its own, together ambiguous about which one describes the page
1444 + * (#494).
1445 + *
1446 + * The answer cannot be read off the rendered page. Third-party FAQ schema
1447 + * is typically printed in `wp_footer` from data its widget only gathers
1448 + * while the body renders, which is long after this graph goes out in
1449 + * `wp_head`; at the moment of the decision the foreign FAQPage does not
1450 + * exist yet, in the buffer or anywhere else. Detection therefore inspects
1451 + * the stored post content, the same way collect_elementor_faq() finds
1452 + * ThinkRank's own widget.
1453 + *
1454 + * @since 2.1.0
1455 + * @return bool
1456 + */
1457 + private function should_emit_faqpage(): bool {
1458 + if (null !== $this->emit_faqpage) {
1459 + return $this->emit_faqpage;
1460 + }
1461 +
1462 + $post = (function_exists('is_singular') && is_singular()) ? get_post() : null;
1463 + if (!$post instanceof \WP_Post) {
1464 + $post = null;
1465 + }
1466 +
1467 + $emit = !$this->has_foreign_faq_source($post);
1468 +
1469 + /**
1470 + * Filter whether ThinkRank emits its FAQPage entity.
1471 + *
1472 + * Return false from a plugin that publishes its own FAQPage on the same
1473 + * URL and ThinkRank drops its FAQ node, leaving the page one
1474 + * unambiguous FAQPage. ThinkRank already defaults this to false for the
1475 + * FAQ sources it recognises, so the filter is for the ones it does not
1476 + * — or for forcing its FAQPage back on.
1477 + *
1478 + * @since 2.1.0
1479 + *
1480 + * @param bool $emit Whether to emit the FAQPage node.
1481 + * @param \WP_Post|null $post Post being viewed, or null when not singular.
1482 + */
1483 + $this->emit_faqpage = (bool) apply_filters('thinkrank_emit_faqpage', $emit, $post);
1484 +
1485 + return $this->emit_faqpage;
1486 + }
1487 +
1488 + /**
1489 + * Whether another plugin publishes a FAQPage for this post.
1490 + *
1491 + * @since 2.1.0
1492 + * @param \WP_Post|null $post Post being viewed.
1493 + * @return bool
1494 + */
1495 + private function has_foreign_faq_source(?\WP_Post $post): bool {
1496 + if (!$post instanceof \WP_Post) {
1497 + return false;
1498 + }
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 {
1511 + $raw = get_post_meta($post->ID, '_elementor_data', true);
1512 + if (empty($raw) || !is_string($raw)) {
1513 + return false;
1514 + }
1515 +
1516 + $elements = json_decode($raw, true);
1517 +
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);
1610 + }
1611 +
1612 + /**
1613 + * Recurse an Elementor element tree looking for a third-party FAQ producer.
1614 + *
1615 + * @since 2.1.0
1616 + * @param array $elements Elementor elements.
1617 + * @return bool
1618 + */
1619 + private function elements_have_foreign_faq(array $elements): bool {
1620 + foreach ($elements as $element) {
1621 + if (!is_array($element)) {
1622 + continue;
1623 + }
1624 +
1625 + // Stored JSON, so nothing guarantees the shape: a non-string
1626 + // widgetType would be an illegal array offset, not a miss.
1627 + $widget = is_string($element['widgetType'] ?? null) ? $element['widgetType'] : '';
1628 + $gate = self::FOREIGN_FAQ_WIDGETS[$widget] ?? '';
1629 + $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1630 +
1631 + if ($gate !== '' && 'yes' === ($settings[$gate] ?? '')) {
1632 + return true;
1633 + }
1634 +
1635 + if (!empty($element['elements']) && is_array($element['elements'])
1636 + && $this->elements_have_foreign_faq($element['elements'])) {
1637 + return true;
1638 + }
1639 + }
1640 +
1641 + return false;
1642 + }
1643 +
1644 + /**
1047 1645 * Build the single FAQ node, if any questions were collected.
1048 1646 *
1647 + * Gated on should_emit_faqpage(): every FAQ source in the plugin — the
1648 + * block, the Elementor widget, a deployed row and the post-type default —
1649 + * funnels through here, so this is the one place that can hold the whole
1650 + * plugin's FAQPage back (#494).
1651 + *
1049 1652 * @since 1.32.0
1050 1653 * @return array|null
1051 1654 */
1052 1655 private function build_faq_node(): ?array {
1053 - if (empty($this->faq_entities)) {
1656 + if (empty($this->faq_entities) || !$this->should_emit_faqpage()) {
1054 1657 return null;
1055 1658 }
1056 1659
1057 1660 return [