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 +99 -230 2.10.0 → 2.11.0 View file →
@@ -105,35 +105,18 @@
105 105 'VideoGallery',
106 106 ];
107 107
108 108 /**
109 - * Gutenberg FAQ block name.
110 - */
111 - private const FAQ_BLOCK = 'thinkrank/faq';
112 -
113 - /**
114 - * Elementor FAQ widget name.
115 - */
116 - private const FAQ_WIDGET = 'thinkrank-faq';
117 -
118 - /**
119 - * Bricks FAQ element 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).
120 114 *
121 - * @since 2.3.1
115 + * @since 2.10.1
122 116 */
123 - private const FAQ_BRICKS_ELEMENT = 'thinkrank-faq';
124 117
125 118 /**
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 119 * Third-party Elementor widgets that publish their own FAQPage.
137 120 *
138 121 * Maps widgetType to the setting whose 'yes' arms that widget's FAQ schema,
139 122 * so an accordion used purely as an accordion never suppresses ours.
@@ -565,265 +548,151 @@
565 548 // page throws `post_content` away, so a FAQ block left there when the
566 549 // page was switched over never renders. Publishing its questions would
567 550 // put schema on the page for content no visitor can see — which Google
568 551 // 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 - }
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 + }
572 560
573 - $this->collect_elementor_faq($post);
574 - $this->collect_bricks_faq($post);
575 - $this->collect_beaver_faq($post);
576 - }
577 -
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;
561 + $this->absorb_content_faq($this->questions_from_pairs($group['pairs']));
592 562 }
593 -
594 - return \ThinkRank\SEO\Builder_Content::bricks_supersedes_post_content($post_id);
595 563 }
596 564
597 565 /**
598 - * Record that a body FAQ producer's content is represented in the graph.
566 + * Whether ThinkRank would publish a FAQPage for this post.
599 567 *
600 - * Deliberately not keyed on the entity count growing: when a block asks the
601 - * same question as the per-post deployment, dedup means nothing is added,
602 - * 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.
603 574 *
604 - * @since 1.32.0
605 - * @param array $entities Questions found on that producer.
606 - * @return void
607 - */
608 - private function absorb_content_faq(array $entities): void {
609 - if (empty($entities)) {
610 - return;
611 - }
612 -
613 - $this->add_faq_entities($entities);
614 - $this->absorbed_content_faq = true;
615 - }
616 -
617 - /**
618 - * 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.
619 579 *
620 - * @since 1.32.0
621 - * @param \WP_Post $post Post being viewed.
622 - * @return void
580 + * @since 2.10.1
581 + * @param \WP_Post $post Post to test.
582 + * @return bool
623 583 */
624 - private function collect_block_faq(\WP_Post $post): void {
625 - if (!function_exists('parse_blocks') || !has_blocks($post->post_content)) {
626 - 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;
627 592 }
628 593
629 - $this->walk_blocks(parse_blocks($post->post_content));
630 - }
594 + $has_questions = false;
631 595
632 - /**
633 - * Recurse a parsed block tree collecting FAQ entries.
634 - *
635 - * @since 1.32.0
636 - * @param array $blocks Parsed blocks.
637 - * @return void
638 - */
639 - private function walk_blocks(array $blocks): void {
640 - foreach ($blocks as $block) {
641 - if (!is_array($block)) {
596 + foreach (self::faq_groups($post) as $group) {
597 + if (empty($group['schema'])) {
642 598 continue;
643 599 }
644 600
645 - $block_name = (string) ($block['blockName'] ?? '');
646 - $attrs = is_array($block['attrs'] ?? null) ? $block['attrs'] : [];
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'];
601 + foreach ($group['pairs'] as $pair) {
602 + if ('' !== trim((string) ($pair['question'] ?? '')) && '' !== trim((string) ($pair['answer'] ?? ''))) {
603 + $has_questions = true;
604 + break 2;
657 605 }
658 606 }
659 -
660 - if ($block_name === self::FAQ_BLOCK) {
661 - // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
662 - $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
663 -
664 - if (!$disabled) {
665 - $this->absorb_content_faq($this->questions_from_pairs($attrs['faqs'] ?? []));
666 - }
667 - }
668 -
669 - if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
670 - $this->walk_blocks($block['innerBlocks']);
671 - }
672 607 }
673 - }
674 608
675 - /**
676 - * Collect FAQ questions from Elementor FAQ widgets.
677 - *
678 - * @since 1.32.0
679 - * @param \WP_Post $post Post being viewed.
680 - * @return void
681 - */
682 - private function collect_elementor_faq(\WP_Post $post): void {
683 - $raw = get_post_meta($post->ID, '_elementor_data', true);
684 - if (empty($raw) || !is_string($raw)) {
685 - return;
609 + if (!$has_questions) {
610 + return false;
686 611 }
687 612
688 - $elements = json_decode($raw, true);
689 - if (!is_array($elements)) {
690 - return;
613 + if (!self::schema_allowed_for_post($post)) {
614 + return false;
691 615 }
692 616
693 - $this->walk_elementor($elements);
694 - }
617 + $emit = !(new self())->has_foreign_faq_source($post);
695 618
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));
619 + /** This filter is documented in includes/frontend/class-schema-graph.php */
620 + return (bool) apply_filters('thinkrank_emit_faqpage', $emit, $post);
714 621 }
715 622
716 623 /**
717 - * Collect FAQ questions from Beaver Builder FAQ modules.
624 + * output_allowed(), asked about a named post instead of the current query.
718 625 *
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.
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.
724 628 *
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
629 + * @since 2.10.1
630 + * @param \WP_Post $post Post to test.
631 + * @return bool
732 632 */
733 - private function collect_beaver_faq(\WP_Post $post): void {
734 - $layout = get_post_meta($post->ID, '_fl_builder_data', true);
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();
637 + }
735 638
736 - if (!is_array($layout) || empty($layout)) {
737 - return;
639 + if (!self::$master_switch_on) {
640 + return false;
738 641 }
739 642
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;
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 + }
743 650
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 - }
651 + return true;
764 652 }
765 653
766 654 /**
767 - * Collect FAQ entries from a resolved Bricks tree.
655 + * Every FAQ producer on a post, loaded defensively.
768 656 *
769 - * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
770 - * splices component definitions into the same list.
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.
771 660 *
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
661 + * @since 2.10.1
662 + * @param \WP_Post $post Post being read.
663 + * @return array<int, array{source: string, schema: bool, pairs: array}>
782 664 */
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;
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 [];
787 670 }
671 + require_once $file;
672 + }
788 673
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 - }
674 + return \ThinkRank\SEO\FAQ_Content::groups($post);
798 675 }
799 676
800 677 /**
801 - * Recurse an Elementor element tree collecting FAQ entries.
678 + * Record that a body FAQ producer's content is represented in the graph.
802 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 + *
803 684 * @since 1.32.0
804 - * @param array $elements Elementor elements.
685 + * @param array $entities Questions found on that producer.
805 686 * @return void
806 687 */
807 - private function walk_elementor(array $elements): void {
808 - foreach ($elements as $element) {
809 - if (!is_array($element)) {
810 - continue;
811 - }
688 + private function absorb_content_faq(array $entities): void {
689 + if (empty($entities)) {
690 + return;
691 + }
812 692
813 - if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) {
814 - $settings = $element['settings'] ?? [];
815 -
816 - // Mirrors FAQ_Widget: schema unless the toggle is off.
817 - if ('yes' === ($settings['output_schema'] ?? 'yes')) {
818 - $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
819 - }
820 - }
821 -
822 - if (!empty($element['elements']) && is_array($element['elements'])) {
823 - $this->walk_elementor($element['elements']);
824 - }
825 - }
693 + $this->add_faq_entities($entities);
694 + $this->absorbed_content_faq = true;
826 695 }
827 696
828 697 /**
829 698 * Turn stored question/answer pairs into Question entities.