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 +856 -19 2.0.12.7.0 View file →
@@ -43,8 +43,20 @@
43 43 */
44 44 private const SCHEMA_CONTEXT = 'https://schema.org';
45 45
46 46 /**
47 + * Entity types that describe the site rather than the current page.
48 + *
49 + * These get a home-scoped @id so the same entity keeps one identity on
50 + * every URL. WebSite and Organization are handled explicitly alongside
51 + * these because they also seed isPartOf/publisher links (#471).
52 + *
53 + * @since 1.16.0
54 + * @var string[]
55 + */
56 + private const SITE_LEVEL_TYPES = ['LocalBusiness', 'Person'];
57 +
58 + /**
47 59 * Which source wins when several subsystems describe the page.
48 60 *
49 61 * Lower wins. Per-post schema deployed from the editor's Schema tab is a
50 62 * deliberate per-post decision, so it outranks the post-type-wide default.
@@ -75,8 +87,26 @@
75 87 'Book', 'Movie', 'Service', 'ImageObject', 'VideoObject',
76 88 ];
77 89
78 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 + /**
79 109 * Gutenberg FAQ block name.
80 110 */
81 111 private const FAQ_BLOCK = 'thinkrank/faq';
82 112
@@ -85,8 +115,53 @@
85 115 */
86 116 private const FAQ_WIDGET = 'thinkrank-faq';
87 117
88 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 + /**
89 164 * Singleton instance.
90 165 *
91 166 * @var self|null
92 167 */
@@ -92,8 +167,16 @@
92 167 */
93 168 private static ?self $instance = null;
94 169
95 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 + /**
96 179 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
97 180 *
98 181 * @var array<int,array>
99 182 */
@@ -113,8 +196,16 @@
113 196 */
114 197 private array $faq_entities = [];
115 198
116 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 + /**
117 208 * Whether FAQ content was taken from the rendered post body (block/widget),
118 209 * meaning those producers must not emit their own duplicate script.
119 210 *
120 211 * @var bool
@@ -167,8 +258,10 @@
167 258 * @return void
168 259 */
169 260 public static function reset(): void {
170 261 self::$instance = null;
262 + // Or a test that seeds the switch inherits the previous test's answer.
263 + self::$master_switch_on = null;
171 264 }
172 265
173 266 /**
174 267 * Register a candidate for the page's single page-level entity.
@@ -189,13 +282,22 @@
189 282 }
190 283
191 284 $type = $this->effective_type($schema, $type);
192 285
193 - if ('FAQPage' === $type) {
286 + if ('FAQPage' === $type && $this->should_emit_faqpage()) {
194 287 $this->add_faq_entities($schema['mainEntity'] ?? []);
195 288 return;
196 289 }
197 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 +
198 300 // A per-post deployment can be something that isn't what the page is
199 301 // about (an Organization, say). Letting it win the slot would drop the
200 302 // page's real entity, so it joins the graph as a supporting node.
201 303 if (!in_array($type, self::PAGE_LEVEL_TYPES, true)) {
@@ -228,8 +330,61 @@
228 330 return (is_string($actual) && $actual !== '') ? $actual : $declared;
229 331 }
230 332
231 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 + /**
232 387 * Register a node that does not compete for the page-level slot.
233 388 *
234 389 * @since 1.32.0
235 390 * @param array $schema Schema array.
@@ -240,17 +395,53 @@
240 395 if (empty($schema)) {
241 396 return;
242 397 }
243 398
244 - if ('FAQPage' === $this->effective_type($schema, $type)) {
245 - $this->add_faq_entities($schema['mainEntity'] ?? []);
399 + $effective_type = $this->effective_type($schema, $type);
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.
406 + if ('FAQPage' === $effective_type) {
407 + if ($this->should_emit_faqpage()) {
408 + $this->add_faq_entities($schema['mainEntity'] ?? []);
409 + }
246 410 return;
247 411 }
248 412
413 + // One breadcrumb trail per page. A deployed BreadcrumbList lands here
414 + // and output_breadcrumb_schema() adds a second on its own wp_head hook,
415 + // so pages ended up with #breadcrumb and #breadcrumb-2 — two conflicting
416 + // trails, with the primary node linking to only one of them (#471).
417 + // First writer wins.
418 + if ('BreadcrumbList' === $effective_type && $this->has_supporting_type('BreadcrumbList')) {
419 + return;
420 + }
421 +
249 422 $this->supporting[] = $schema;
250 423 }
251 424
252 425 /**
426 + * Whether a supporting node of the given type has already been collected.
427 + *
428 + * @since 1.16.0
429 + *
430 + * @param string $type Schema type.
431 + * @return bool
432 + */
433 + private function has_supporting_type(string $type): bool {
434 + foreach ($this->supporting as $node) {
435 + if (($node['@type'] ?? '') === $type) {
436 + return true;
437 + }
438 + }
439 +
440 + return false;
441 + }
442 +
443 + /**
253 444 * Merge FAQ questions into the single FAQ node, deduped by question text.
254 445 *
255 446 * @since 1.32.0
256 447 * @param mixed $entities Candidate Question entities.
@@ -369,13 +560,42 @@
369 560 if (function_exists('post_password_required') && post_password_required($post)) {
370 561 return;
371 562 }
372 563
373 - $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 +
374 573 $this->collect_elementor_faq($post);
574 + $this->collect_bricks_faq($post);
575 + $this->collect_beaver_faq($post);
375 576 }
376 577
377 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 + /**
378 598 * Record that a body FAQ producer's content is represented in the graph.
379 599 *
380 600 * Deliberately not keyed on the entity count growing: when a block asks the
381 601 * same question as the per-post deployment, dedup means nothing is added,
@@ -460,8 +680,112 @@
460 680 $this->walk_elementor($elements);
461 681 }
462 682
463 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 + /**
464 788 * Recurse an Elementor element tree collecting FAQ entries.
465 789 *
466 790 * @since 1.32.0
467 791 * @param array $elements Elementor elements.
@@ -515,15 +839,13 @@
515 839 }
516 840
517 841 $text = wp_kses_post($answer);
518 842
519 - // Mirrors Blocks_Manager::build_faq_schema(): a per-item image is
520 - // carried inside the answer HTML (Yoast-style).
521 - $image_url = isset($pair['imageUrl']) ? esc_url((string) $pair['imageUrl']) : '';
522 - if ($image_url !== '') {
523 - $image_alt = isset($pair['imageAlt']) ? esc_attr((string) $pair['imageAlt']) : '';
524 - $text .= ' <img src="' . $image_url . '" alt="' . $image_alt . '" />';
525 - }
843 + // Mirrors Blocks_Manager::build_faq_schema() by calling the same
844 + // builder, so the two paths cannot drift — the per-item image is
845 + // resolved from its attachment id, carries intrinsic dimensions,
846 + // and disappears if the media was deleted (#418).
847 + $text .= \ThinkRank\Editor\Blocks_Manager::faq_image_markup(is_array($pair) ? $pair : []);
526 848
527 849 $entities[] = [
528 850 '@type' => 'Question',
529 851 'name' => $question,
@@ -547,8 +869,59 @@
547 869 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
548 870 }
549 871
550 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 + /**
551 924 * Assemble and emit the graph. Safe to call more than once.
552 925 *
553 926 * @since 1.32.0
554 927 * @return void
@@ -557,8 +930,23 @@
557 930 if ($this->rendered || !$this->has_nodes()) {
558 931 return;
559 932 }
560 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 +
940 + // A 404 response represents no content, so there is nothing for
941 + // structured data to describe. The page-level producers already skip
942 + // this context, but the site-identity entity does not, so without this
943 + // guard every miss — including crawlers probing URLs that never existed
944 + // — emits a Person carrying email, telephone and birthDate (#481).
945 + if (is_404()) {
946 + return;
947 + }
948 +
561 949 $this->rendered = true;
562 950
563 951 $graph = $this->build_graph();
564 952
@@ -573,12 +961,29 @@
573 961 * @param array $graph List of schema nodes ([] suppresses output).
574 962 */
575 963 $graph = apply_filters('thinkrank_schema_graph', $graph);
576 964
965 + // Drop empty properties across every node. An empty string is worse
966 + // than an absent one — "headline": "" fails Article validation harder
967 + // than omitting it — and Schema_Builder::clean_schema_array(), which was
968 + // written for exactly this, is never reached from the render path
969 + // (#471). Runs after the filter so add-on nodes are cleaned too.
970 + $graph = array_values(array_filter(array_map([$this, 'prune_empty_values'], $graph)));
971 +
577 972 if (empty($graph)) {
578 973 return;
579 974 }
580 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 +
581 986 $json = wp_json_encode(
582 987 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
583 988 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
584 989 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -595,8 +1000,96 @@
595 1000 echo "<!-- /ThinkRank Schema Graph -->\n";
596 1001 }
597 1002
598 1003 /**
1004 + * Replace an inline entity with an @id reference to an equivalent node.
1005 + *
1006 + * Matches on name so a post author is never silently collapsed into the
1007 + * site's Person entity, and vice versa (#471).
1008 + *
1009 + * @since 1.16.0
1010 + *
1011 + * @param mixed $inline The inline entity from the primary node.
1012 + * @param array $candidates Nodes already in the graph, each with an @id.
1013 + * @return array|null ['@id' => …] when a match is found, null otherwise.
1014 + */
1015 + private function link_to_node($inline, array $candidates): ?array {
1016 + if (!is_array($inline) || empty($candidates)) {
1017 + return null;
1018 + }
1019 +
1020 + // Already a reference.
1021 + if (isset($inline['@id']) && !isset($inline['name'])) {
1022 + return null;
1023 + }
1024 +
1025 + $inline_name = isset($inline['name']) ? trim((string) $inline['name']) : '';
1026 +
1027 + if ('' === $inline_name) {
1028 + return null;
1029 + }
1030 +
1031 + foreach ($candidates as $candidate) {
1032 + $candidate_name = isset($candidate['name']) ? trim((string) $candidate['name']) : '';
1033 +
1034 + if ('' !== $candidate_name
1035 + && 0 === strcasecmp($candidate_name, $inline_name)
1036 + && !empty($candidate['@id'])
1037 + ) {
1038 + return ['@id' => $candidate['@id']];
1039 + }
1040 + }
1041 +
1042 + return null;
1043 + }
1044 +
1045 + /**
1046 + * Recursively drop empty properties from a schema node.
1047 + *
1048 + * Removes '', [], and null. Deliberately keeps numeric 0, boolean false and
1049 + * the structural keys, which are all meaningful values.
1050 + *
1051 + * @since 1.16.0
1052 + *
1053 + * @param mixed $value Node or property value.
1054 + * @return mixed Cleaned value.
1055 + */
1056 + private function prune_empty_values($value) {
1057 + if (!is_array($value)) {
1058 + return $value;
1059 + }
1060 +
1061 + $cleaned = [];
1062 +
1063 + foreach ($value as $key => $item) {
1064 + // Never prune the keys that give a node its identity.
1065 + if (in_array($key, ['@context', '@type', '@id'], true)) {
1066 + $cleaned[$key] = $item;
1067 + continue;
1068 + }
1069 +
1070 + if (is_array($item)) {
1071 + $item = $this->prune_empty_values($item);
1072 +
1073 + if ([] === $item) {
1074 + continue;
1075 + }
1076 +
1077 + $cleaned[$key] = $item;
1078 + continue;
1079 + }
1080 +
1081 + if (null === $item || '' === $item) {
1082 + continue;
1083 + }
1084 +
1085 + $cleaned[$key] = $item;
1086 + }
1087 +
1088 + return $cleaned;
1089 + }
1090 +
1091 + /**
599 1092 * Build the linked node list.
600 1093 *
601 1094 * @since 1.32.0
602 1095 * @return array
@@ -613,11 +1106,12 @@
613 1106 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
614 1107 $faq = null;
615 1108 }
616 1109
617 - $nodes = [];
618 - $primary_id = '';
619 - $used_ids = [];
1110 + $nodes = [];
1111 + $primary_id = '';
1112 + $primary_type = '';
1113 + $used_ids = [];
620 1114
621 1115 if (null !== $primary) {
622 1116 $node = $primary['schema'];
623 1117
@@ -625,9 +1119,10 @@
625 1119 // so an "Article" setting that renders BlogPosting reads #blogposting.
626 1120 $resolved_type = $this->effective_type($node, $primary['type']);
627 1121
628 1122 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
629 - $primary_id = $node['@id'];
1123 + $primary_id = $node['@id'];
1124 + $primary_type = $resolved_type;
630 1125 $nodes['primary'] = $node;
631 1126 }
632 1127
633 1128 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -661,10 +1156,12 @@
661 1156
662 1157 $nodes['faq'] = $faq;
663 1158 }
664 1159
665 - $website_id = '';
666 - $breadcrumb_id = '';
1160 + $website_id = '';
1161 + $breadcrumb_id = '';
1162 + $organization_nodes = [];
1163 + $person_nodes = [];
667 1164
668 1165 foreach ($this->supporting as $index => $node) {
669 1166 $type = $node['@type'] ?? '';
670 1167
@@ -675,8 +1172,33 @@
675 1172 $node = $this->assign_id($node, home_url('/#website'), $used_ids);
676 1173 $website_id = $node['@id'];
677 1174 } elseif ('Organization' === $type) {
678 1175 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
1176 + $organization_nodes[] = $node;
1177 + } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
1178 + // Site-level entities describe the site, not the page, so their
1179 + // @id must be stable across URLs. Falling through to the
1180 + // page-scoped branch minted a fresh identity on every URL, so
1181 + // one business became N entities in a crawler's graph and
1182 + // nothing could reference it by @id (#471).
1183 + // One entity, emitted once. The site identity and a per-post
1184 + // deployment describe the same person or business, so both
1185 + // arrive here claiming the same @id. assign_id() would resolve
1186 + // that collision by minting "#person-2", turning a duplicate
1187 + // into two competing entities that split the identity a
1188 + // knowledge graph is meant to consolidate (#479).
1189 + $duplicate_key = $this->find_same_entity($nodes, $type, $node);
1190 +
1191 + if (null !== $duplicate_key) {
1192 + $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
1193 + continue;
1194 + }
1195 +
1196 + $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
1197 +
1198 + if ('Person' === $type) {
1199 + $person_nodes[] = $node;
1200 + }
679 1201 } elseif (is_string($type) && $type !== '') {
680 1202 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
681 1203 }
682 1204
@@ -687,11 +1209,39 @@
687 1209 if (isset($nodes['primary'])) {
688 1210 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
689 1211 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
690 1212 }
691 - 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 + ) {
692 1218 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
693 1219 }
1220 +
1221 + // Point publisher/author at the full nodes already in the graph.
1222 + // They were emitted inline with no @id, so the graph described the
1223 + // same publisher twice — and the richer node, the one carrying the
1224 + // logo Google needs for Article, was not the one publisher
1225 + // referenced (#471).
1226 + //
1227 + // Only collapse when the inline object names the SAME entity. A post
1228 + // author and the site's Person entity are frequently different
1229 + // people, so matching on position rather than identity would
1230 + // misattribute authorship.
1231 + if (isset($nodes['primary']['publisher'])) {
1232 + $linked = $this->link_to_node($nodes['primary']['publisher'], $organization_nodes);
1233 + if (null !== $linked) {
1234 + $nodes['primary']['publisher'] = $linked;
1235 + }
1236 + }
1237 +
1238 + if (isset($nodes['primary']['author'])) {
1239 + $linked = $this->link_to_node($nodes['primary']['author'], $person_nodes);
1240 + if (null !== $linked) {
1241 + $nodes['primary']['author'] = $linked;
1242 + }
1243 + }
694 1244 }
695 1245
696 1246 // The graph carries @context once; per-node copies are redundant.
697 1247 foreach ($nodes as $key => $node) {
@@ -739,8 +1289,84 @@
739 1289 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
740 1290 }
741 1291
742 1292 /**
1293 + * Find an already-placed node describing the same entity as $node.
1294 + *
1295 + * Identity is `email` when both carry one — two people can share a name,
1296 + * but not a mailbox — and a case-insensitive `name` match otherwise. A node
1297 + * with neither never matches, so an unidentifiable entity is kept rather
1298 + * than folded into an unrelated one.
1299 + *
1300 + * @since 2.0.2
1301 + *
1302 + * @param array $nodes Nodes placed so far, keyed.
1303 + * @param string $type Schema type to match within.
1304 + * @param array $node Candidate node.
1305 + * @return string|null Key of the matching node, or null.
1306 + */
1307 + private function find_same_entity(array $nodes, string $type, array $node): ?string {
1308 + $email = isset($node['email']) ? strtolower(trim((string) $node['email'])) : '';
1309 + $name = isset($node['name']) ? trim((string) $node['name']) : '';
1310 +
1311 + if ('' === $email && '' === $name) {
1312 + return null;
1313 + }
1314 +
1315 + foreach ($nodes as $key => $placed) {
1316 + if (($placed['@type'] ?? '') !== $type) {
1317 + continue;
1318 + }
1319 +
1320 + $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';
1321 +
1322 + if ('' !== $email && '' !== $placed_email) {
1323 + if ($email === $placed_email) {
1324 + return (string) $key;
1325 + }
1326 + continue;
1327 + }
1328 +
1329 + $placed_name = isset($placed['name']) ? trim((string) $placed['name']) : '';
1330 +
1331 + if ('' !== $name && '' !== $placed_name && 0 === strcasecmp($name, $placed_name)) {
1332 + return (string) $key;
1333 + }
1334 + }
1335 +
1336 + return null;
1337 + }
1338 +
1339 + /**
1340 + * Fold a duplicate entity into the node already in the graph.
1341 + *
1342 + * Fills gaps only: a property the placed node already carries wins, so the
1343 + * node that claimed the identity first keeps it, @id included. The
1344 + * duplicate can still contribute properties the first copy lacked, which is
1345 + * the point — between them they describe the entity more completely than
1346 + * either does alone.
1347 + *
1348 + * @since 2.0.2
1349 + *
1350 + * @param array $placed Node already in the graph.
1351 + * @param array $duplicate Node describing the same entity.
1352 + * @return array Merged node.
1353 + */
1354 + private function merge_entity(array $placed, array $duplicate): array {
1355 + foreach ($duplicate as $key => $value) {
1356 + if ('@id' === $key || '@type' === $key || '@context' === $key) {
1357 + continue;
1358 + }
1359 +
1360 + if (!isset($placed[$key]) || '' === $placed[$key] || [] === $placed[$key]) {
1361 + $placed[$key] = $value;
1362 + }
1363 + }
1364 +
1365 + return $placed;
1366 + }
1367 +
1368 + /**
743 1369 * Give a node a unique @id, keeping one it already carries.
744 1370 *
745 1371 * Two entities of the same type on one page (two deployed Articles, say)
746 1372 * would otherwise mint the same @id, which makes the graph ambiguous about
@@ -771,15 +1397,226 @@
771 1397 return $node;
772 1398 }
773 1399
774 1400 /**
1401 + * Whether ThinkRank should emit a FAQPage on this request.
1402 + *
1403 + * ThinkRank emitted its FAQPage unconditionally, so a URL whose FAQ was
1404 + * already published by another plugin carried two FAQPage entities — each
1405 + * valid on its own, together ambiguous about which one describes the page
1406 + * (#494).
1407 + *
1408 + * The answer cannot be read off the rendered page. Third-party FAQ schema
1409 + * is typically printed in `wp_footer` from data its widget only gathers
1410 + * while the body renders, which is long after this graph goes out in
1411 + * `wp_head`; at the moment of the decision the foreign FAQPage does not
1412 + * exist yet, in the buffer or anywhere else. Detection therefore inspects
1413 + * the stored post content, the same way collect_elementor_faq() finds
1414 + * ThinkRank's own widget.
1415 + *
1416 + * @since 2.1.0
1417 + * @return bool
1418 + */
1419 + private function should_emit_faqpage(): bool {
1420 + if (null !== $this->emit_faqpage) {
1421 + return $this->emit_faqpage;
1422 + }
1423 +
1424 + $post = (function_exists('is_singular') && is_singular()) ? get_post() : null;
1425 + if (!$post instanceof \WP_Post) {
1426 + $post = null;
1427 + }
1428 +
1429 + $emit = !$this->has_foreign_faq_source($post);
1430 +
1431 + /**
1432 + * Filter whether ThinkRank emits its FAQPage entity.
1433 + *
1434 + * Return false from a plugin that publishes its own FAQPage on the same
1435 + * URL and ThinkRank drops its FAQ node, leaving the page one
1436 + * unambiguous FAQPage. ThinkRank already defaults this to false for the
1437 + * FAQ sources it recognises, so the filter is for the ones it does not
1438 + * — or for forcing its FAQPage back on.
1439 + *
1440 + * @since 2.1.0
1441 + *
1442 + * @param bool $emit Whether to emit the FAQPage node.
1443 + * @param \WP_Post|null $post Post being viewed, or null when not singular.
1444 + */
1445 + $this->emit_faqpage = (bool) apply_filters('thinkrank_emit_faqpage', $emit, $post);
1446 +
1447 + return $this->emit_faqpage;
1448 + }
1449 +
1450 + /**
1451 + * Whether another plugin publishes a FAQPage for this post.
1452 + *
1453 + * @since 2.1.0
1454 + * @param \WP_Post|null $post Post being viewed.
1455 + * @return bool
1456 + */
1457 + private function has_foreign_faq_source(?\WP_Post $post): bool {
1458 + if (!$post instanceof \WP_Post) {
1459 + return false;
1460 + }
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 {
1473 + $raw = get_post_meta($post->ID, '_elementor_data', true);
1474 + if (empty($raw) || !is_string($raw)) {
1475 + return false;
1476 + }
1477 +
1478 + $elements = json_decode($raw, true);
1479 +
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);
1572 + }
1573 +
1574 + /**
1575 + * Recurse an Elementor element tree looking for a third-party FAQ producer.
1576 + *
1577 + * @since 2.1.0
1578 + * @param array $elements Elementor elements.
1579 + * @return bool
1580 + */
1581 + private function elements_have_foreign_faq(array $elements): bool {
1582 + foreach ($elements as $element) {
1583 + if (!is_array($element)) {
1584 + continue;
1585 + }
1586 +
1587 + // Stored JSON, so nothing guarantees the shape: a non-string
1588 + // widgetType would be an illegal array offset, not a miss.
1589 + $widget = is_string($element['widgetType'] ?? null) ? $element['widgetType'] : '';
1590 + $gate = self::FOREIGN_FAQ_WIDGETS[$widget] ?? '';
1591 + $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1592 +
1593 + if ($gate !== '' && 'yes' === ($settings[$gate] ?? '')) {
1594 + return true;
1595 + }
1596 +
1597 + if (!empty($element['elements']) && is_array($element['elements'])
1598 + && $this->elements_have_foreign_faq($element['elements'])) {
1599 + return true;
1600 + }
1601 + }
1602 +
1603 + return false;
1604 + }
1605 +
1606 + /**
775 1607 * Build the single FAQ node, if any questions were collected.
776 1608 *
1609 + * Gated on should_emit_faqpage(): every FAQ source in the plugin — the
1610 + * block, the Elementor widget, a deployed row and the post-type default —
1611 + * funnels through here, so this is the one place that can hold the whole
1612 + * plugin's FAQPage back (#494).
1613 + *
777 1614 * @since 1.32.0
778 1615 * @return array|null
779 1616 */
780 1617 private function build_faq_node(): ?array {
781 - if (empty($this->faq_entities)) {
1618 + if (empty($this->faq_entities) || !$this->should_emit_faqpage()) {
782 1619 return null;
783 1620 }
784 1621
785 1622 return [