| @@ -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. |