PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.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 All 53 releases
← All changes | includes/frontend/class-schema-graph.php +130 -223 2.9.0 → 2.12.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,252 +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 - if (($block['blockName'] ?? '') === self::FAQ_BLOCK) {
646 - $attrs = $block['attrs'] ?? [];
647 -
648 - // Mirrors Blocks_Manager: schema is on unless explicitly disabled.
649 - $disabled = array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema'];
650 -
651 - if (!$disabled) {
652 - $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;
653 605 }
654 606 }
655 -
656 - if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
657 - $this->walk_blocks($block['innerBlocks']);
658 - }
659 607 }
660 - }
661 608
662 - /**
663 - * Collect FAQ questions from Elementor FAQ widgets.
664 - *
665 - * @since 1.32.0
666 - * @param \WP_Post $post Post being viewed.
667 - * @return void
668 - */
669 - private function collect_elementor_faq(\WP_Post $post): void {
670 - $raw = get_post_meta($post->ID, '_elementor_data', true);
671 - if (empty($raw) || !is_string($raw)) {
672 - return;
609 + if (!$has_questions) {
610 + return false;
673 611 }
674 612
675 - $elements = json_decode($raw, true);
676 - if (!is_array($elements)) {
677 - return;
613 + if (!self::schema_allowed_for_post($post)) {
614 + return false;
678 615 }
679 616
680 - $this->walk_elementor($elements);
681 - }
617 + $emit = !(new self())->has_foreign_faq_source($post);
682 618
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));
619 + /** This filter is documented in includes/frontend/class-schema-graph.php */
620 + return (bool) apply_filters('thinkrank_emit_faqpage', $emit, $post);
701 621 }
702 622
703 623 /**
704 - * Collect FAQ questions from Beaver Builder FAQ modules.
624 + * output_allowed(), asked about a named post instead of the current query.
705 625 *
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.
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.
711 628 *
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
629 + * @since 2.10.1
630 + * @param \WP_Post $post Post to test.
631 + * @return bool
719 632 */
720 - private function collect_beaver_faq(\WP_Post $post): void {
721 - $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 + }
722 638
723 - if (!is_array($layout) || empty($layout)) {
724 - return;
639 + if (!self::$master_switch_on) {
640 + return false;
725 641 }
726 642
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;
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 + }
730 650
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 - }
651 + return true;
751 652 }
752 653
753 654 /**
754 - * Collect FAQ entries from a resolved Bricks tree.
655 + * Every FAQ producer on a post, loaded defensively.
755 656 *
756 - * The tree is flat, so no recursion: `Builder_Content::bricks_tree()`
757 - * 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.
758 660 *
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
661 + * @since 2.10.1
662 + * @param \WP_Post $post Post being read.
663 + * @return array<int, array{source: string, schema: bool, pairs: array}>
769 664 */
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;
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 [];
774 670 }
671 + require_once $file;
672 + }
775 673
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 - }
674 + return \ThinkRank\SEO\FAQ_Content::groups($post);
785 675 }
786 676
787 677 /**
788 - * Recurse an Elementor element tree collecting FAQ entries.
678 + * Record that a body FAQ producer's content is represented in the graph.
789 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 + *
790 684 * @since 1.32.0
791 - * @param array $elements Elementor elements.
685 + * @param array $entities Questions found on that producer.
792 686 * @return void
793 687 */
794 - private function walk_elementor(array $elements): void {
795 - foreach ($elements as $element) {
796 - if (!is_array($element)) {
797 - continue;
798 - }
688 + private function absorb_content_faq(array $entities): void {
689 + if (empty($entities)) {
690 + return;
691 + }
799 692
800 - if (($element['widgetType'] ?? '') === self::FAQ_WIDGET) {
801 - $settings = $element['settings'] ?? [];
802 -
803 - // Mirrors FAQ_Widget: schema unless the toggle is off.
804 - if ('yes' === ($settings['output_schema'] ?? 'yes')) {
805 - $this->absorb_content_faq($this->questions_from_pairs($settings['faqs'] ?? []));
806 - }
807 - }
808 -
809 - if (!empty($element['elements']) && is_array($element['elements'])) {
810 - $this->walk_elementor($element['elements']);
811 - }
812 - }
693 + $this->add_faq_entities($entities);
694 + $this->absorbed_content_faq = true;
813 695 }
814 696
815 697 /**
816 698 * Turn stored question/answer pairs into Question entities.
@@ -1163,8 +1045,9 @@
1163 1045 $person_nodes = [];
1164 1046
1165 1047 foreach ($this->supporting as $index => $node) {
1166 1048 $type = $node['@type'] ?? '';
1049 + $slot = $this->site_level_slot($type);
1167 1050
1168 1051 if ('BreadcrumbList' === $type) {
1169 1052 $node = $this->assign_id($node, $base . '#breadcrumb', $used_ids);
1170 1053 $breadcrumb_id = $node['@id'];
@@ -1173,9 +1056,9 @@
1173 1056 $website_id = $node['@id'];
1174 1057 } elseif ('Organization' === $type) {
1175 1058 $node = $this->assign_id($node, home_url('/#organization'), $used_ids);
1176 1059 $organization_nodes[] = $node;
1177 - } elseif (in_array($type, self::SITE_LEVEL_TYPES, true)) {
1060 + } elseif ('' !== $slot) {
1178 1061 // Site-level entities describe the site, not the page, so their
1179 1062 // @id must be stable across URLs. Falling through to the
1180 1063 // page-scoped branch minted a fresh identity on every URL, so
1181 1064 // one business became N entities in a crawler's graph and
@@ -1185,9 +1068,9 @@
1185 1068 // arrive here claiming the same @id. assign_id() would resolve
1186 1069 // that collision by minting "#person-2", turning a duplicate
1187 1070 // into two competing entities that split the identity a
1188 1071 // knowledge graph is meant to consolidate (#479).
1189 - $duplicate_key = $this->find_same_entity($nodes, $type, $node);
1072 + $duplicate_key = $this->find_same_entity($nodes, $slot, $node);
1190 1073
1191 1074 if (null !== $duplicate_key) {
1192 1075 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
1193 1076 continue;
@@ -1192,11 +1075,11 @@
1192 1075 $nodes[$duplicate_key] = $this->merge_entity($nodes[$duplicate_key], $node);
1193 1076 continue;
1194 1077 }
1195 1078
1196 - $node = $this->assign_id($node, home_url('/#' . strtolower($type)), $used_ids);
1079 + $node = $this->assign_id($node, home_url('/#' . strtolower($slot)), $used_ids);
1197 1080
1198 - if ('Person' === $type) {
1081 + if ('Person' === $slot) {
1199 1082 $person_nodes[] = $node;
1200 1083 }
1201 1084 } elseif (is_string($type) && $type !== '') {
1202 1085 $node = $this->assign_id($node, $base . '#' . strtolower($type), $used_ids);
@@ -1289,8 +1172,32 @@
1289 1172 return ['winner' => array_shift($kept), 'siblings' => array_values($kept)];
1290 1173 }
1291 1174
1292 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 + /**
1293 1200 * Find an already-placed node describing the same entity as $node.
1294 1201 *
1295 1202 * Identity is `email` when both carry one — two people can share a name,
1296 1203 * but not a mailbox — and a case-insensitive `name` match otherwise. A node
@@ -1299,9 +1206,9 @@
1299 1206 *
1300 1207 * @since 2.0.2
1301 1208 *
1302 1209 * @param array $nodes Nodes placed so far, keyed.
1303 - * @param string $type Schema type to match within.
1210 + * @param string $type Site-level slot to match within (see site_level_slot()).
1304 1211 * @param array $node Candidate node.
1305 1212 * @return string|null Key of the matching node, or null.
1306 1213 */
1307 1214 private function find_same_entity(array $nodes, string $type, array $node): ?string {
@@ -1312,9 +1219,9 @@
1312 1219 return null;
1313 1220 }
1314 1221
1315 1222 foreach ($nodes as $key => $placed) {
1316 - if (($placed['@type'] ?? '') !== $type) {
1223 + if ($this->site_level_slot($placed['@type'] ?? '') !== $type) {
1317 1224 continue;
1318 1225 }
1319 1226
1320 1227 $placed_email = isset($placed['email']) ? strtolower(trim((string) $placed['email'])) : '';