PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.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 +579 -107 2.0.2 → 2.11.0 View file →
@@ -87,18 +87,64 @@
87 87 'Book', 'Movie', 'Service', 'ImageObject', 'VideoObject',
88 88 ];
89 89
90 90 /**
91 - * Gutenberg FAQ block name.
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>
92 100 */
93 - private const FAQ_BLOCK = 'thinkrank/faq';
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 + ];
94 107
95 108 /**
96 - * Elementor FAQ widget name.
109 + * The FAQ surfaces themselves are read by FAQ_Content, which owns their
110 + * names, their stored shapes and their per-surface schema toggles. Only the
111 + * *foreign* FAQ producers are listed below, because standing down for them
112 + * is this class's decision rather than a fact about where ThinkRank keeps
113 + * its own content (#767).
114 + *
115 + * @since 2.10.1
97 116 */
98 - private const FAQ_WIDGET = 'thinkrank-faq';
99 117
100 118 /**
119 + * Third-party Elementor widgets that publish their own FAQPage.
120 + *
121 + * Maps widgetType to the setting whose 'yes' arms that widget's FAQ schema,
122 + * so an accordion used purely as an accordion never suppresses ours.
123 + *
124 + * @since 2.1.0
125 + * @var array<string,string>
126 + */
127 + private const FOREIGN_FAQ_WIDGETS = [
128 + // Essential Addons for Elementor — Advanced Accordion.
129 + 'eael-adv-accordion' => 'eael_adv_accordion_faq_schema_show',
130 + ];
131 +
132 + /**
133 + * Bricks elements that publish their own FAQPage.
134 + *
135 + * Bricks is a theme, not a plugin, and its accordions are core elements
136 + * rather than a third-party add-on — so unlike FOREIGN_FAQ_WIDGETS this is
137 + * a plain list: they share one gate, the `faqSchema` setting, and the
138 + * per-element part of the check is whether the element has usable items
139 + * (see bricks_element_publishes_faq()).
140 + *
141 + * @since 2.3.1
142 + * @var string[]
143 + */
144 + private const FOREIGN_FAQ_BRICKS_ELEMENTS = ['accordion', 'accordion-nested'];
145 +
146 + /**
101 147 * Singleton instance.
102 148 *
103 149 * @var self|null
104 150 */
@@ -104,8 +150,16 @@
104 150 */
105 151 private static ?self $instance = null;
106 152
107 153 /**
154 + * Memoised master switch, or null when it has not been read this request.
155 + *
156 + * @since 2.7.0
157 + * @var bool|null
158 + */
159 + private static ?bool $master_switch_on = null;
160 +
161 + /**
108 162 * Competing page-level entities: ['rank' => int, 'schema' => array, 'type' => string].
109 163 *
110 164 * @var array<int,array>
111 165 */
@@ -125,8 +179,16 @@
125 179 */
126 180 private array $faq_entities = [];
127 181
128 182 /**
183 + * Memoized answer to "should this request emit a FAQPage at all?".
184 + *
185 + * @since 2.1.0
186 + * @var bool|null
187 + */
188 + private ?bool $emit_faqpage = null;
189 +
190 + /**
129 191 * Whether FAQ content was taken from the rendered post body (block/widget),
130 192 * meaning those producers must not emit their own duplicate script.
131 193 *
132 194 * @var bool
@@ -179,8 +241,10 @@
179 241 * @return void
180 242 */
181 243 public static function reset(): void {
182 244 self::$instance = null;
245 + // Or a test that seeds the switch inherits the previous test's answer.
246 + self::$master_switch_on = null;
183 247 }
184 248
185 249 /**
186 250 * Register a candidate for the page's single page-level entity.
@@ -201,13 +265,22 @@
201 265 }
202 266
203 267 $type = $this->effective_type($schema, $type);
204 268
205 - if ('FAQPage' === $type) {
269 + if ('FAQPage' === $type && $this->should_emit_faqpage()) {
206 270 $this->add_faq_entities($schema['mainEntity'] ?? []);
207 271 return;
208 272 }
209 273
274 + // A third party owns the page's FAQPage, so ours must not be emitted
275 + // (#494). Demote rather than drop: a FAQPage is still the page, and
276 + // returning here would leave the URL with no page-level entity at all.
277 + if ('FAQPage' === $type) {
278 + $schema['@type'] = 'WebPage';
279 + unset($schema['mainEntity']);
280 + $type = 'WebPage';
281 + }
282 +
210 283 // A per-post deployment can be something that isn't what the page is
211 284 // about (an Organization, say). Letting it win the slot would drop the
212 285 // page's real entity, so it joins the graph as a supporting node.
213 286 if (!in_array($type, self::PAGE_LEVEL_TYPES, true)) {
@@ -240,8 +313,61 @@
240 313 return (is_string($actual) && $actual !== '') ? $actual : $declared;
241 314 }
242 315
243 316 /**
317 + * Whether a page entity may carry a `breadcrumb` property.
318 + *
319 + * Reading the node's own `@type` key is not enough, and every case it
320 + * misses ends with a real WebPage losing a valid property:
321 + *
322 + * - A deployed schema whose stored JSON omits `@type` carries the type in
323 + * the `schema_type` column instead. `effective_type()` already resolves
324 + * that, which is why the node's `@id` reads `#webpage` even though the
325 + * node itself has no `@type` — so the resolved value is what has to be
326 + * consulted here too.
327 + * - JSON-LD permits several types on one node. `["WebPage", "FAQPage"]` is
328 + * a WebPage, but a strict in_array() against the array as a whole is
329 + * false, so the trail would be dropped from a page that may carry it.
330 + * - A list naming nothing usable (`[]`, `[null]`) is the first case wearing
331 + * the second's clothes, and resolves the same way.
332 + *
333 + * @since 2.7.0
334 + * @param array $node The page entity.
335 + * @param string $resolved_type Type the graph resolved for it.
336 + * @return bool
337 + */
338 + private function allows_breadcrumb(array $node, string $resolved_type): bool {
339 + $declared = $node['@type'] ?? '';
340 +
341 + // One path for both shapes. Splitting them invites the list branch to
342 + // grow its own idea of what an absent type means, which is the mistake
343 + // being corrected here in the first place.
344 + $named = false;
345 +
346 + foreach (is_array($declared) ? $declared : [$declared] as $type) {
347 + if (!is_string($type) || '' === $type) {
348 + continue;
349 + }
350 +
351 + $named = true;
352 +
353 + if (in_array($type, self::BREADCRUMB_TYPES, true)) {
354 + return true;
355 + }
356 + }
357 +
358 + // The node named a type, and none of them may carry a breadcrumb.
359 + if ($named) {
360 + return false;
361 + }
362 +
363 + // It named none, so it is whatever the graph resolved for it — the same
364 + // value its @id was minted from. An empty list is no more informative
365 + // than a missing key and must not read as "definitely not a WebPage".
366 + return in_array($resolved_type, self::BREADCRUMB_TYPES, true);
367 + }
368 +
369 + /**
244 370 * Register a node that does not compete for the page-level slot.
245 371 *
246 372 * @since 1.32.0
247 373 * @param array $schema Schema array.
@@ -254,10 +380,17 @@
254 380 }
255 381
256 382 $effective_type = $this->effective_type($schema, $type);
257 383
384 + // A supporting FAQPage never survives as its own node: its questions
385 + // merge into the graph's single FAQ node, or are dropped when a third
386 + // party already owns the page's FAQPage (#494). Unlike the primary
387 + // slot there is nothing to preserve here, so demotion would only add a
388 + // second page-level entity beside the real one.
258 389 if ('FAQPage' === $effective_type) {
259 - $this->add_faq_entities($schema['mainEntity'] ?? []);
390 + if ($this->should_emit_faqpage()) {
391 + $this->add_faq_entities($schema['mainEntity'] ?? []);
392 + }
260 393 return;
261 394 }
262 395
263 396 // One breadcrumb trail per page. A deployed BreadcrumbList lands here
@@ -410,124 +543,156 @@
410 543 if (function_exists('post_password_required') && post_password_required($post)) {
411 544 return;
412 545 }
413 546
414 - $this->collect_block_faq($post);
415 - $this->collect_elementor_faq($post);
547 + // The same gate, for the same reason, with a different cause: a Bricks
548 + // page throws `post_content` away, so a FAQ block left there when the
549 + // page was switched over never renders. Publishing its questions would
550 + // put schema on the page for content no visitor can see — which Google
551 + // treats as a violation, not merely a duplicate (#650).
552 + foreach (self::faq_groups($post) as $group) {
553 + // The stored toggle decides whether a producer contributes schema.
554 + // FAQ_Content reports it rather than applying it, because a block
555 + // with schema switched off is still visible FAQ content that the
556 + // abilities have to describe (#767).
557 + if (empty($group['schema'])) {
558 + continue;
559 + }
560 +
561 + $this->absorb_content_faq($this->questions_from_pairs($group['pairs']));
562 + }
416 563 }
417 564
418 565 /**
419 - * Record that a body FAQ producer's content is represented in the graph.
566 + * Whether ThinkRank would publish a FAQPage for this post.
420 567 *
421 - * Deliberately not keyed on the entity count growing: when a block asks the
422 - * same question as the per-post deployment, dedup means nothing is added,
423 - * but the block's content *is* covered and it must still stay quiet.
568 + * Answers the question `get-faq` has to put to an agent — "is this actually
569 + * being emitted?" — without rendering the page. Kept here rather than in the
570 + * ability because every term of it is this class's own rule: the password
571 + * form, the master switch, the per-content-type Schema switch, the other
572 + * plugins whose FAQ output makes ThinkRank stand down, and the filter that
573 + * overrides all of it.
424 574 *
425 - * @since 1.32.0
426 - * @param array $entities Questions found on that producer.
427 - * @return void
428 - */
429 - private function absorb_content_faq(array $entities): void {
430 - if (empty($entities)) {
431 - return;
432 - }
433 -
434 - $this->add_faq_entities($entities);
435 - $this->absorbed_content_faq = true;
436 - }
437 -
438 - /**
439 - * Collect FAQ questions from thinkrank/faq blocks, including nested ones.
575 + * It answers for the post as a visitor gets it. A draft or a scheduled post
576 + * is reported on what it will emit once it is served, because that is the
577 + * question an agent preparing one is asking; a password-protected post is
578 + * not, because publishing it changes nothing — the form stays.
440 579 *
441 - * @since 1.32.0
442 - * @param \WP_Post $post Post being viewed.
443 - * @return void
580 + * @since 2.10.1
581 + * @param \WP_Post $post Post to test.
582 + * @return bool
444 583 */
445 - private function collect_block_faq(\WP_Post $post): void {
446 - if (!function_exists('parse_blocks') || !has_blocks($post->post_content)) {
447 - return;
584 + public static function will_emit_faqpage(\WP_Post $post): bool {
585 + // The same gate collect_post_faq() applies, and for the same reason:
586 + // behind a password form the block never renders, so no FAQPage is
587 + // published. Without this the ability answered "yes, it is emitted"
588 + // about a page that emits nothing — which is the answer that stops an
589 + // agent looking any further.
590 + if (function_exists('post_password_required') && post_password_required($post)) {
591 + return false;
448 592 }
449 593
450 - $this->walk_blocks(parse_blocks($post->post_content));
451 - }
594 + $has_questions = false;
452 595
453 - /**
454 - * Recurse a parsed block tree collecting FAQ entries.
455 - *
456 - * @since 1.32.0
457 - * @param array $blocks Parsed blocks.
458 - * @return void
459 - */
460 - private function walk_blocks(array $blocks): void {
461 - foreach ($blocks as $block) {
462 - if (!is_array($block)) {
596 + foreach (self::faq_groups($post) as $group) {
597 + if (empty($group['schema'])) {
463 598 continue;
464 599 }
465 600
466 - if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
467 - $attrs = $block['attrs'] ?? [];
468 -
469 - // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
470 - $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
471 -
472 - if (!$disabled) {
473 - $this->absorb_content_faq($this->questions_from_pairs($attrs['faqs'] ?? []));
601 + foreach ($group['pairs'] as $pair) {
602 + if ('' !== trim((string) ($pair['question'] ?? '')) && '' !== trim((string) ($pair['answer'] ?? ''))) {
603 + $has_questions = true;
604 + break 2;
474 605 }
475 606 }
607 + }
476 608
477 - if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
478 - $this->walk_blocks($block['innerBlocks']);
479 - }
609 + if (!$has_questions) {
610 + return false;
480 611 }
612 +
613 + if (!self::schema_allowed_for_post($post)) {
614 + return false;
615 + }
616 +
617 + $emit = !(new self())->has_foreign_faq_source($post);
618 +
619 + /** This filter is documented in includes/frontend/class-schema-graph.php */
620 + return (bool) apply_filters('thinkrank_emit_faqpage', $emit, $post);
481 621 }
482 622
483 623 /**
484 - * Collect FAQ questions from Elementor FAQ widgets.
624 + * output_allowed(), asked about a named post instead of the current query.
485 625 *
486 - * @since 1.32.0
487 - * @param \WP_Post $post Post being viewed.
488 - * @return void
626 + * output_allowed() resolves the content type from the main query, which in
627 + * an admin or MCP request is not the post being asked about.
628 + *
629 + * @since 2.10.1
630 + * @param \WP_Post $post Post to test.
631 + * @return bool
489 632 */
490 - private function collect_elementor_faq(\WP_Post $post): void {
491 - $raw = get_post_meta($post->ID, '_elementor_data', true);
492 - if (empty($raw) || !is_string($raw)) {
493 - return;
633 + private static function schema_allowed_for_post(\WP_Post $post): bool {
634 + if (null === self::$master_switch_on) {
635 + // Resolve the site-wide half through the existing reader.
636 + self::output_allowed();
494 637 }
495 638
496 - $elements = json_decode($raw, true);
497 - if (!is_array($elements)) {
498 - return;
639 + if (!self::$master_switch_on) {
640 + return false;
499 641 }
500 642
501 - $this->walk_elementor($elements);
643 + if (class_exists('ThinkRank\\SEO\\Content_Type_Settings')) {
644 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled(
645 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
646 + (string) $post->post_type,
647 + true
648 + );
649 + }
650 +
651 + return true;
502 652 }
503 653
504 654 /**
505 - * Recurse an Elementor element tree collecting FAQ entries.
655 + * Every FAQ producer on a post, loaded defensively.
506 656 *
507 - * @since 1.32.0
508 - * @param array $elements Elementor elements.
509 - * @return void
657 + * The reader lives in the SEO namespace, and this class runs in contexts
658 + * where that autoloader is not guaranteed — which is why the Bricks gate it
659 + * replaced carried the same require.
660 + *
661 + * @since 2.10.1
662 + * @param \WP_Post $post Post being read.
663 + * @return array<int, array{source: string, schema: bool, pairs: array}>
510 664 */
511 - private function walk_elementor(array $elements): void {
512 - foreach ($elements as $element) {
513 - if (!is_array($element)) {
514 - continue;
665 + private static function faq_groups(\WP_Post $post): array {
666 + if (!class_exists('ThinkRank\\SEO\\FAQ_Content')) {
667 + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-faq-content.php';
668 + if (!file_exists($file)) {
669 + return [];
515 670 }
671 + require_once $file;
672 + }
516 673
517 - if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) {
518 - $settings = $element['settings'] ?? [];
674 + return \ThinkRank\SEO\FAQ_Content::groups($post);
675 + }
519 676
520 - // Mirrors FAQ_Widget: schema unless the toggle is off.
521 - if ('yes' === ($settings['output_schema'] ?? 'yes')) {
522 - $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
523 - }
524 - }
677 + /**
678 + * Record that a body FAQ producer's content is represented in the graph.
679 + *
680 + * Deliberately not keyed on the entity count growing: when a block asks the
681 + * same question as the per-post deployment, dedup means nothing is added,
682 + * but the block's content *is* covered and it must still stay quiet.
683 + *
684 + * @since 1.32.0
685 + * @param array $entities Questions found on that producer.
686 + * @return void
687 + */
688 + private function absorb_content_faq(array $entities): void {
689 + if (empty($entities)) {
690 + return;
691 + }
525 692
526 - if (!empty($element['elements']) && is_array($element['elements'])) {
527 - $this->walk_elementor($element['elements']);
528 - }
529 - }
693 + $this->add_faq_entities($entities);
694 + $this->absorbed_content_faq = true;
530 695 }
531 696
532 697 /**
533 698 * Turn stored question/answer pairs into Question entities.
@@ -556,15 +721,13 @@
556 721 }
557 722
558 723 $text = wp_kses_post($answer);
559 724
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 - }
725 + // Mirrors Blocks_Manager::build_faq_schema() by calling the same
726 + // builder, so the two paths cannot drift — the per-item image is
727 + // resolved from its attachment id, carries intrinsic dimensions,
728 + // and disappears if the media was deleted (#418).
729 + $text .= \ThinkRank\Editor\Blocks_Manager::faq_image_markup(is_array($pair) ? $pair : []);
567 730
568 731 $entities[] = [
569 732 '@type' => 'Question',
570 733 'name' => $question,
@@ -588,8 +751,59 @@
588 751 return !empty($this->primary_candidates) || !empty($this->supporting) || !empty($this->faq_entities);
589 752 }
590 753
591 754 /**
755 + * Whether ThinkRank may emit structured data for this request at all.
756 + *
757 + * The master switch and the matrix's per-content-type Schema switch, asked
758 + * once. #461 put the master switch inside output_site_schema_markup(),
759 + * which is one of four producers; the other three never learned about it,
760 + * so turning Schema off removed the deployed rows and left the live
761 + * generator running — the page emitted *more* types with the switch off
762 + * than with it on (#688).
763 + *
764 + * Static because the producers that need it do not share a base class: the
765 + * three graph producers converge on render(), but Blocks_Manager emits its
766 + * own script tag from a content filter and never touches the graph, so it
767 + * has to ask the same question independently.
768 + *
769 + * @since 2.7.0
770 + * @return bool True when structured data may be emitted.
771 + */
772 + public static function output_allowed(): bool {
773 + // Memoised because inject_block_schema() asks once per matching block,
774 + // and Schema_Management_System's constructor builds a schema builder and
775 + // a cache manager and registers listeners — it is not something to spin
776 + // up per block. The switch is site-wide, so it cannot change within a
777 + // request; the per-content-type check below is query-dependent and stays
778 + // live.
779 + if (null === self::$master_switch_on) {
780 + self::$master_switch_on = true;
781 +
782 + if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
783 + $settings = (new \ThinkRank\SEO\Schema_Management_System())->get_settings('site', null);
784 +
785 + // Absent means "not configured", which every other reader treats
786 + // as enabled; only a value that is present and off disables.
787 + self::$master_switch_on = !(array_key_exists('enabled', $settings) && empty($settings['enabled']));
788 + }
789 + }
790 +
791 + if (!self::$master_switch_on) {
792 + return false;
793 + }
794 +
795 + if (class_exists('ThinkRank\\SEO\\Content_Type_Settings')) {
796 + return \ThinkRank\SEO\Content_Type_Settings::is_enabled_for_current(
797 + \ThinkRank\SEO\Content_Type_Settings::FEATURE_SCHEMA,
798 + true
799 + );
800 + }
801 +
802 + return true;
803 + }
804 +
805 + /**
592 806 * Assemble and emit the graph. Safe to call more than once.
593 807 *
594 808 * @since 1.32.0
595 809 * @return void
@@ -598,8 +812,14 @@
598 812 if ($this->rendered || !$this->has_nodes()) {
599 813 return;
600 814 }
601 815
816 + // Every graph producer converges here, so this is the one place the
817 + // master switch has to hold for all of them (#688).
818 + if (!self::output_allowed()) {
819 + return;
820 + }
821 +
602 822 // A 404 response represents no content, so there is nothing for
603 823 // structured data to describe. The page-level producers already skip
604 824 // this context, but the site-identity entity does not, so without this
605 825 // guard every miss — including crawlers probing URLs that never existed
@@ -634,8 +854,18 @@
634 854 if (empty($graph)) {
635 855 return;
636 856 }
637 857
858 + // One pass over the assembled graph, rather than at each producer.
859 + // @id and url values arrive from a dozen of them — some derived from
860 + // WordPress, some read straight out of stored settings — and on a
861 + // misconfigured site that produced a single graph carrying both
862 + // schemes at once, with @ids that no longer matched the canonical they
863 + // are supposed to identify (#638). Normalizing where the graph is
864 + // serialized is the only place that catches all of them, including
865 + // nodes an add-on added through the filter above.
866 + $graph = \ThinkRank\SEO\Url_Scheme::apply_deep($graph);
867 +
638 868 $json = wp_json_encode(
639 869 ['@context' => self::SCHEMA_CONTEXT, '@graph' => array_values($graph)],
640 870 JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
641 871 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
@@ -645,9 +875,9 @@
645 875 return;
646 876 }
647 877
648 878 echo "<!-- ThinkRank Schema Graph -->\n";
649 - echo '<script type="application/ld+json">' . "\n";
879 + echo '<script type="application/ld+json" data-thinkrank="schema-graph">' . "\n";
650 880 echo $json . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode with JSON_HEX_* cannot break out of the script block.
651 881 echo '</script>' . "\n";
652 882 echo "<!-- /ThinkRank Schema Graph -->\n";
653 883 }
@@ -758,11 +988,12 @@
758 988 $primary = ['schema' => $faq, 'type' => 'FAQPage'];
759 989 $faq = null;
760 990 }
761 991
762 - $nodes = [];
763 - $primary_id = '';
764 - $used_ids = [];
992 + $nodes = [];
993 + $primary_id = '';
994 + $primary_type = '';
995 + $used_ids = [];
765 996
766 997 if (null !== $primary) {
767 998 $node = $primary['schema'];
768 999
@@ -770,9 +1001,10 @@
770 1001 // so an "Article" setting that renders BlogPosting reads #blogposting.
771 1002 $resolved_type = $this->effective_type($node, $primary['type']);
772 1003
773 1004 $node = $this->assign_id($node, $base . '#' . strtolower($resolved_type), $used_ids);
774 - $primary_id = $node['@id'];
1005 + $primary_id = $node['@id'];
1006 + $primary_type = $resolved_type;
775 1007 $nodes['primary'] = $node;
776 1008 }
777 1009
778 1010 // Entities deployed alongside the winner (Pro's Multi-Schema lets a post
@@ -813,8 +1045,9 @@
813 1045 $person_nodes = [];
814 1046
815 1047 foreach ($this->supporting as $index => $node) {
816 1048 $type = $node['@type'] ?? '';
1049 + $slot = $this->site_level_slot($type);
817 1050
818 1051 if ('BreadcrumbList' === $type) {
819 1052 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
820 1053 $breadcrumb_id = $node['@id'];
@@ -823,9 +1056,9 @@
823 1056 $website_id = $node['@id'];
824 1057 } elseif ('Organization' === $type) {
825 1058 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
826 1059 $organization_nodes[] = $node;
827 - } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
1060 + } elseif ('' !== $slot) {
828 1061 // Site-level entities describe the site, not the page, so their
829 1062 // @id must be stable across URLs. Falling through to the
830 1063 // page-scoped branch minted a fresh identity on every URL, so
831 1064 // one business became N entities in a crawler's graph and
@@ -835,9 +1068,9 @@
835 1068 // arrive here claiming the same @id. assign_id() would resolve
836 1069 // that collision by minting "#person-2", turning a duplicate
837 1070 // into two competing entities that split the identity a
838 1071 // knowledge graph is meant to consolidate (#479).
839 - $duplicate_key = $this->find_same_entity($nodes, $type, $node);
1072 + $duplicate_key = $this->find_same_entity($nodes, $slot, $node);
840 1073
841 1074 if (null !== $duplicate_key) {
842 1075 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
843 1076 continue;
@@ -842,11 +1075,11 @@
842 1075 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
843 1076 continue;
844 1077 }
845 1078
846 - $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
1079 + $node = $this->assign_id($node, home_url('/#' . strtolower($slot)), $used_ids);
847 1080
848 - if ('Person' === $type) {
1081 + if ('Person' === $slot) {
849 1082 $person_nodes[] = $node;
850 1083 }
851 1084 } elseif (is_string($type) && $type !== '') {
852 1085 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
@@ -859,9 +1092,13 @@
859 1092 if (isset($nodes['primary'])) {
860 1093 if ($website_id !== '' && !isset($nodes['primary']['isPartOf'])) {
861 1094 $nodes['primary']['isPartOf'] = ['@id' => $website_id];
862 1095 }
863 - if ($breadcrumb_id !== '' && !isset($nodes['primary']['breadcrumb'])) {
1096 + if (
1097 + $breadcrumb_id !== ''
1098 + && !isset($nodes['primary']['breadcrumb'])
1099 + && $this->allows_breadcrumb($nodes['primary'], $primary_type)
1100 + ) {
864 1101 $nodes['primary']['breadcrumb'] = ['@id' => $breadcrumb_id];
865 1102 }
866 1103
867 1104 // Point publisher/author at the full nodes already in the graph.
@@ -935,8 +1172,32 @@
935 1172 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
936 1173 }
937 1174
938 1175 /**
1176 + * The site-level entity a node's @type makes it, or '' for none.
1177 + *
1178 + * A LocalBusiness is published under the subtype the site chose in Local
1179 + * SEO ("Dentist", "Restaurant"), but it is still the one business entity,
1180 + * so every subtype shares the LocalBusiness slot: the same home-scoped
1181 + * `/#localbusiness` @id it always had, and the same entity for
1182 + * find_same_entity() to fold a per-post copy into. Matching the literal
1183 + * type instead would have given a Dentist a fresh page-scoped @id on every
1184 + * URL, the exact split #471 closed.
1185 + *
1186 + * @since 2.10.0
1187 + *
1188 + * @param mixed $type Node @type.
1189 + * @return string 'LocalBusiness', 'Person' or ''.
1190 + */
1191 + private function site_level_slot($type): string {
1192 + if (\ThinkRank\Config\Local_Business_Types_Config::is_local_business($type)) {
1193 + return 'LocalBusiness';
1194 + }
1195 +
1196 + return (is_string($type) && in_array($type, self::SITE_LEVEL_TYPES, true)) ? $type : '';
1197 + }
1198 +
1199 + /**
939 1200 * Find an already-placed node describing the same entity as $node.
940 1201 *
941 1202 * Identity is `email` when both carry one — two people can share a name,
942 1203 * but not a mailbox — and a case-insensitive `name` match otherwise. A node
@@ -945,9 +1206,9 @@
945 1206 *
946 1207 * @since 2.0.2
947 1208 *
948 1209 * @param array $nodes Nodes placed so far, keyed.
949 - * @param string $type Schema type to match within.
1210 + * @param string $type Site-level slot to match within (see site_level_slot()).
950 1211 * @param array $node Candidate node.
951 1212 * @return string|null Key of the matching node, or null.
952 1213 */
953 1214 private function find_same_entity(array $nodes, string $type, array $node): ?string {
@@ -958,9 +1219,9 @@
958 1219 return null;
959 1220 }
960 1221
961 1222 foreach ($nodes as $key => $placed) {
962 - if (($placed['@type'] ?? '') !== $type) {
1223 + if ($this->site_level_slot($placed['@type'] ?? '') !== $type) {
963 1224 continue;
964 1225 }
965 1226
966 1227 $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';
@@ -1043,15 +1304,226 @@
1043 1304 return $node;
1044 1305 }
1045 1306
1046 1307 /**
1308 + * Whether ThinkRank should emit a FAQPage on this request.
1309 + *
1310 + * ThinkRank emitted its FAQPage unconditionally, so a URL whose FAQ was
1311 + * already published by another plugin carried two FAQPage entities — each
1312 + * valid on its own, together ambiguous about which one describes the page
1313 + * (#494).
1314 + *
1315 + * The answer cannot be read off the rendered page. Third-party FAQ schema
1316 + * is typically printed in `wp_footer` from data its widget only gathers
1317 + * while the body renders, which is long after this graph goes out in
1318 + * `wp_head`; at the moment of the decision the foreign FAQPage does not
1319 + * exist yet, in the buffer or anywhere else. Detection therefore inspects
1320 + * the stored post content, the same way collect_elementor_faq() finds
1321 + * ThinkRank's own widget.
1322 + *
1323 + * @since 2.1.0
1324 + * @return bool
1325 + */
1326 + private function should_emit_faqpage(): bool {
1327 + if (null !== $this->emit_faqpage) {
1328 + return $this->emit_faqpage;
1329 + }
1330 +
1331 + $post = (function_exists('is_singular') && is_singular()) ? get_post() : null;
1332 + if (!$post instanceof \WP_Post) {
1333 + $post = null;
1334 + }
1335 +
1336 + $emit = !$this->has_foreign_faq_source($post);
1337 +
1338 + /**
1339 + * Filter whether ThinkRank emits its FAQPage entity.
1340 + *
1341 + * Return false from a plugin that publishes its own FAQPage on the same
1342 + * URL and ThinkRank drops its FAQ node, leaving the page one
1343 + * unambiguous FAQPage. ThinkRank already defaults this to false for the
1344 + * FAQ sources it recognises, so the filter is for the ones it does not
1345 + * — or for forcing its FAQPage back on.
1346 + *
1347 + * @since 2.1.0
1348 + *
1349 + * @param bool $emit Whether to emit the FAQPage node.
1350 + * @param \WP_Post|null $post Post being viewed, or null when not singular.
1351 + */
1352 + $this->emit_faqpage = (bool) apply_filters('thinkrank_emit_faqpage', $emit, $post);
1353 +
1354 + return $this->emit_faqpage;
1355 + }
1356 +
1357 + /**
1358 + * Whether another plugin publishes a FAQPage for this post.
1359 + *
1360 + * @since 2.1.0
1361 + * @param \WP_Post|null $post Post being viewed.
1362 + * @return bool
1363 + */
1364 + private function has_foreign_faq_source(?\WP_Post $post): bool {
1365 + if (!$post instanceof \WP_Post) {
1366 + return false;
1367 + }
1368 +
1369 + return $this->has_foreign_elementor_faq($post) || $this->has_foreign_bricks_faq($post);
1370 + }
1371 +
1372 + /**
1373 + * Whether an Elementor widget on this post publishes a FAQPage.
1374 + *
1375 + * @since 2.1.0
1376 + * @param \WP_Post $post Post being viewed.
1377 + * @return bool
1378 + */
1379 + private function has_foreign_elementor_faq(\WP_Post $post): bool {
1380 + $raw = get_post_meta($post->ID, '_elementor_data', true);
1381 + if (empty($raw) || !is_string($raw)) {
1382 + return false;
1383 + }
1384 +
1385 + $elements = json_decode($raw, true);
1386 +
1387 + return is_array($elements) && $this->elements_have_foreign_faq($elements);
1388 + }
1389 +
1390 + /**
1391 + * Whether a Bricks element on this post publishes a FAQPage.
1392 + *
1393 + * Bricks' accordions emit their FAQPage from the body render, so — exactly
1394 + * as with EA's accordion — the stored tree is the only signal available at
1395 + * `wp_head`, where this decision has to be made.
1396 + *
1397 + * The tree comes from Builder_Content rather than a direct meta read: a
1398 + * Bricks page's content can live on a content template, be assembled from
1399 + * components, or be stored but not rendered because the post was switched
1400 + * back to the block editor. Reading the meta key here would get all three
1401 + * wrong (#649).
1402 + *
1403 + * @since 2.3.1
1404 + * @param \WP_Post $post Post being viewed.
1405 + * @return bool
1406 + */
1407 + private function has_foreign_bricks_faq(\WP_Post $post): bool {
1408 + foreach ($this->bricks_tree((int) $post->ID) as $element) {
1409 + if (is_array($element) && $this->bricks_element_publishes_faq($element)) {
1410 + return true;
1411 + }
1412 + }
1413 +
1414 + return false;
1415 + }
1416 +
1417 + /**
1418 + * Whether one Bricks element will put a FAQPage on the page.
1419 + *
1420 + * Mirrors Bricks' own emission condition rather than trusting the toggle:
1421 + * `accordion` records a question only for an item that has BOTH a title and
1422 + * content, so an armed but empty accordion publishes nothing and must not
1423 + * cost the page ThinkRank's FAQ node. `accordion-nested` builds its items
1424 + * from child elements instead of a repeater, so having children is the
1425 + * equivalent test there.
1426 + *
1427 + * @since 2.3.1
1428 + * @param array $element One Bricks element.
1429 + * @return bool
1430 + */
1431 + private function bricks_element_publishes_faq(array $element): bool {
1432 + $name = is_string($element['name'] ?? null) ? $element['name'] : '';
1433 + if (!in_array($name, self::FOREIGN_FAQ_BRICKS_ELEMENTS, true)) {
1434 + return false;
1435 + }
1436 +
1437 + $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1438 +
1439 + // Bricks writes a checkbox as `true`, and clears it by removing the key.
1440 + if (empty($settings['faqSchema'])) {
1441 + return false;
1442 + }
1443 +
1444 + if ('accordion-nested' === $name) {
1445 + return !empty($element['children']) && is_array($element['children']);
1446 + }
1447 +
1448 + $items = is_array($settings['accordions'] ?? null) ? $settings['accordions'] : [];
1449 +
1450 + foreach ($items as $item) {
1451 + if (is_array($item)
1452 + && '' !== trim((string) ($item['title'] ?? ''))
1453 + && '' !== trim((string) ($item['content'] ?? ''))
1454 + ) {
1455 + return true;
1456 + }
1457 + }
1458 +
1459 + return false;
1460 + }
1461 +
1462 + /**
1463 + * The Bricks element tree that renders for a post.
1464 + *
1465 + * @since 2.3.1
1466 + * @param int $post_id Post being viewed.
1467 + * @return array<int,mixed>
1468 + */
1469 + private function bricks_tree(int $post_id): array {
1470 + if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
1471 + $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1472 + if (!file_exists($file)) {
1473 + return [];
1474 + }
1475 + require_once $file;
1476 + }
1477 +
1478 + return \ThinkRank\SEO\Builder_Content::bricks_tree($post_id);
1479 + }
1480 +
1481 + /**
1482 + * Recurse an Elementor element tree looking for a third-party FAQ producer.
1483 + *
1484 + * @since 2.1.0
1485 + * @param array $elements Elementor elements.
1486 + * @return bool
1487 + */
1488 + private function elements_have_foreign_faq(array $elements): bool {
1489 + foreach ($elements as $element) {
1490 + if (!is_array($element)) {
1491 + continue;
1492 + }
1493 +
1494 + // Stored JSON, so nothing guarantees the shape: a non-string
1495 + // widgetType would be an illegal array offset, not a miss.
1496 + $widget = is_string($element['widgetType'] ?? null) ? $element['widgetType'] : '';
1497 + $gate = self::FOREIGN_FAQ_WIDGETS[$widget] ?? '';
1498 + $settings = is_array($element['settings'] ?? null) ? $element['settings'] : [];
1499 +
1500 + if ($gate !== '' && 'yes' === ($settings[$gate] ?? '')) {
1501 + return true;
1502 + }
1503 +
1504 + if (!empty($element['elements']) && is_array($element['elements'])
1505 + && $this->elements_have_foreign_faq($element['elements'])) {
1506 + return true;
1507 + }
1508 + }
1509 +
1510 + return false;
1511 + }
1512 +
1513 + /**
1047 1514 * Build the single FAQ node, if any questions were collected.
1048 1515 *
1516 + * Gated on should_emit_faqpage(): every FAQ source in the plugin — the
1517 + * block, the Elementor widget, a deployed row and the post-type default —
1518 + * funnels through here, so this is the one place that can hold the whole
1519 + * plugin's FAQPage back (#494).
1520 + *
1049 1521 * @since 1.32.0
1050 1522 * @return array|null
1051 1523 */
1052 1524 private function build_faq_node(): ?array {
1053 - if (empty($this->faq_entities)) {
1525 + if (empty($this->faq_entities) || !$this->should_emit_faqpage()) {
1054 1526 return null;
1055 1527 }
1056 1528
1057 1529 return [