*/ public static function catalog(): array { return array( 'wprm' => array( 'label' => 'WP Recipe Maker', 'provides' => 'Recipe schema (times, yield, nutrition, ingredients)', 'detect' => static fn(): bool => class_exists( 'WPRM_Recipe_Manager' ), ), 'tec' => array( 'label' => 'The Events Calendar', 'provides' => 'Event schema (dates, venue, offers)', 'detect' => static fn(): bool => class_exists( 'Tribe__Events__Main' ), ), 'wpjm' => array( 'label' => 'WP Job Manager', 'provides' => 'JobPosting schema (salary, location, employment type)', 'detect' => static fn(): bool => class_exists( 'WP_Job_Manager' ), ), 'coauthors' => array( 'label' => 'Co-Authors Plus', 'provides' => 'Multiple authors on Article schema', 'detect' => static fn(): bool => function_exists( 'get_coauthors' ), ), 'publishpress' => array( 'label' => 'PublishPress Authors', 'provides' => 'Multiple authors on Article schema', 'detect' => static fn(): bool => function_exists( 'get_multiple_authors' ), ), 'postratings' => array( 'label' => 'WP-PostRatings', 'provides' => 'aggregateRating on any post type', 'detect' => static fn(): bool => function_exists( 'get_post_ratings' ), ), 'ratemypost' => array( 'label' => 'Rate my Post', 'provides' => 'aggregateRating on any post type', 'detect' => static fn(): bool => defined( 'RMP_PLUGIN_VERSION' ) || class_exists( 'RMP_Main' ), ), 'perfectbrands' => array( 'label' => 'Perfect Brands for WooCommerce', 'provides' => 'Product brand', 'detect' => static fn(): bool => taxonomy_exists( 'pwb-brand' ), ), 'yithbrands' => array( 'label' => 'YITH WooCommerce Brands', 'provides' => 'Product brand', 'detect' => static fn(): bool => taxonomy_exists( 'yith_product_brand' ), ), 'woobrands' => array( 'label' => 'WooCommerce Brands', 'provides' => 'Product brand', 'detect' => static fn(): bool => taxonomy_exists( 'product_brand' ), ), 'judgeme' => array( 'label' => 'Judge.me Reviews', 'provides' => 'Product reviews and rating', 'detect' => static fn(): bool => class_exists( 'Judgeme_Reviews' ) || defined( 'JUDGEME_VERSION' ), ), 'yotpo' => array( 'label' => 'Yotpo Reviews', 'provides' => 'Product reviews and rating', 'detect' => static fn(): bool => class_exists( 'YotpoSettings' ) || defined( 'YOTPO_VERSION' ), ), 'wpml' => array( 'label' => 'WPML', 'provides' => 'Language alternates and per-language schema', 'detect' => static fn(): bool => defined( 'ICL_SITEPRESS_VERSION' ), ), 'polylang' => array( 'label' => 'Polylang', 'provides' => 'Language alternates and per-language schema', 'detect' => static fn(): bool => function_exists( 'pll_the_languages' ), ), 'translatepress' => array( 'label' => 'TranslatePress', 'provides' => 'Language alternates and per-language output', 'detect' => static fn(): bool => defined( 'TRP_PLUGIN_VERSION' ) || class_exists( 'TRP_Translate_Press' ), ), 'weglot' => array( 'label' => 'Weglot', 'provides' => 'Language alternates and per-language output', 'detect' => static fn(): bool => defined( 'WEGLOT_VERSION' ) || function_exists( 'weglot_get_current_language' ), ), 'divi' => array( 'label' => 'Divi Builder', 'provides' => 'Rendered builder content for analysis and schema', 'detect' => static fn(): bool => defined( 'ET_BUILDER_VERSION' ) || function_exists( 'et_pb_is_pagebuilder_used' ), ), 'beaver' => array( 'label' => 'Beaver Builder', 'provides' => 'Rendered builder content for analysis and schema', 'detect' => static fn(): bool => class_exists( 'FLBuilder' ), ), 'bricks' => array( 'label' => 'Bricks', 'provides' => 'Rendered builder content for analysis and schema', 'detect' => static fn(): bool => defined( 'BRICKS_VERSION' ), ), 'oxygen' => array( 'label' => 'Oxygen', 'provides' => 'Rendered builder content for analysis and schema', 'detect' => static fn(): bool => defined( 'CT_VERSION' ), ), 'wpbakery' => array( 'label' => 'WPBakery Page Builder', 'provides' => 'Rendered builder content for analysis and schema', 'detect' => static fn(): bool => defined( 'WPB_VC_VERSION' ), ), 'elementor' => array( 'label' => 'Elementor', 'provides' => 'Rendered builder content for analysis and schema', 'detect' => static fn(): bool => defined( 'ELEMENTOR_VERSION' ), ), 'acf' => array( 'label' => 'Advanced Custom Fields', 'provides' => 'Custom field values as schema variables', 'detect' => static fn(): bool => function_exists( 'get_field' ), ), ); } public function register( Container $container ): void {} public function boot( Container $container ): void { // Elementor is the only editor a great many customers ever open. Services\ElementorPanel::boot(); add_action( 'rest_api_init', array( $this, 'routes' ) ); // Page builders keep content outside post_content — without this the // analyzer scores an empty page and every check fails on a real site. add_filter( 'airanksuite/content/raw', array( $this, 'builder_content' ), 10, 2 ); // Enrich the graph with whatever the active plugins know. add_filter( 'airanksuite/schema/graph', array( $this, 'enrich_graph' ), 15, 2 ); // hreflang alternates from the active translation plugin. Guard::action( 'wp_head', array( $this, 'hreflang' ), 3 ); } /** * Page-builder output, so analysis sees what visitors see. * * @param string $content Raw content. * @param WP_Post $post The post. */ public function builder_content( string $content, $post ): string { if ( ! $post instanceof WP_Post ) { return $content; } // Elementor stores its layout in meta and renders through its own engine. if ( defined( 'ELEMENTOR_VERSION' ) && class_exists( '\Elementor\Plugin' ) ) { $built = get_post_meta( $post->ID, '_elementor_edit_mode', true ); if ( 'builder' === $built ) { try { $rendered = \Elementor\Plugin::instance()->frontend->get_builder_content( $post->ID, false ); if ( is_string( $rendered ) && strlen( $rendered ) > strlen( $content ) ) { return $rendered; } } catch ( \Throwable $e ) { return $content; // A builder error must never break analysis. } } } // Divi keeps its layout in shortcodes inside post_content, which the // default the_content filter does not expand outside the loop. if ( function_exists( 'et_pb_is_pagebuilder_used' ) && et_pb_is_pagebuilder_used( $post->ID ) ) { try { $rendered = do_shortcode( $content ); if ( is_string( $rendered ) && strlen( $rendered ) > strlen( $content ) ) { return $rendered; } } catch ( \Throwable $e ) { return $content; } } // Beaver Builder renders from its own layout store. if ( class_exists( 'FLBuilderModel' ) && class_exists( 'FLBuilder' ) ) { try { if ( \FLBuilderModel::is_builder_enabled( $post->ID ) ) { // The level is recorded so the catch below closes exactly // the buffer opened here and never one that belonged to // somebody else: "if any buffer is open, close one" would // take a theme's or a caching plugin's buffer when the // builder threw after ours was already cleaned. $level = ob_get_level(); ob_start(); try { \FLBuilder::render_content_by_id( $post->ID ); } finally { $rendered = ob_get_level() > $level ? (string) ob_get_clean() : ''; } if ( strlen( $rendered ) > strlen( $content ) ) { return $rendered; } } } catch ( \Throwable $e ) { return $content; } } // Bricks and Oxygen store their trees in meta and render through the // theme, so the honest fallback is the text they hold rather than an // empty string that scores every check as failed. foreach ( array( '_bricks_page_content_2', 'ct_builder_shortcodes' ) as $meta_key ) { $stored = get_post_meta( $post->ID, $meta_key, true ); if ( empty( $stored ) ) { continue; } $text = is_array( $stored ) ? wp_json_encode( $stored ) : (string) $stored; $text = (string) preg_replace( '/\[[^\]]*\]/', ' ', (string) $text ); $text = trim( (string) wp_strip_all_tags( $text ) ); if ( strlen( $text ) > strlen( wp_strip_all_tags( $content ) ) ) { return $text; } } return $content; } /** * Add nodes and properties the source plugins already know about. * * @param array> $graph The graph. * @param WP_Post|null $post Current post. * @return array> */ public function enrich_graph( array $graph, $post ): array { if ( ! $post instanceof WP_Post ) { return $graph; } $authors = Services\Readers::authors( $post ); $rating = Services\Readers::rating( $post ); $recipe = Services\Readers::recipe( $post ); $event = Services\Readers::event( $post ); $job = Services\Readers::job( $post ); foreach ( $graph as $i => $node ) { $types = (array) ( $node['@type'] ?? array() ); if ( ! empty( $authors ) && array_intersect( array( 'Article', 'BlogPosting', 'NewsArticle' ), $types ) ) { $graph[ $i ]['author'] = count( $authors ) === 1 ? $authors[0] : $authors; } if ( null !== $rating && array_intersect( array( 'Article', 'BlogPosting', 'NewsArticle', 'Product', 'Recipe' ), $types ) ) { // Never overwrite a rating the shop itself provided. if ( empty( $node['aggregateRating'] ) ) { $graph[ $i ]['aggregateRating'] = $rating; } } } foreach ( array( $recipe, $event, $job ) as $node ) { if ( ! empty( $node ) ) { $graph[] = $node; } } return $graph; } /** * hreflang alternates — the single most-requested multilingual SEO tag. */ public function hreflang(): void { // Coexistence decides who owns hreflang; emitting a second set next to // WPML's or Yoast's is worse than emitting none. if ( ! \AiRankSuite\Modules\Coexistence\CoexistenceModule::owns( 'hreflang' ) ) { return; } $post = is_singular() ? get_queried_object() : null; $alternates = \AiRankSuite\Support\Multilingual::alternates( $post instanceof WP_Post ? $post : null ); foreach ( $alternates as $locale => $url ) { printf( '' . "\n", esc_attr( $locale ), esc_url( $url ) ); } } public function routes(): void { register_rest_route( self::NAMESPACE, '/integrations', array( 'methods' => 'GET', 'callback' => array( $this, 'status' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ) ); } public function status(): WP_REST_Response { $out = array(); foreach ( self::catalog() as $key => $meta ) { $active = false; try { $active = (bool) call_user_func( $meta['detect'] ); } catch ( \Throwable $e ) { $active = false; } $out[] = array( 'key' => $key, 'label' => $meta['label'], 'provides' => $meta['provides'], 'active' => $active, ); } return new WP_REST_Response( array( 'integrations' => $out ) ); } }