[ 'Yoast SEO', 'WPSEO_VERSION' ], 'wordpress-seo-premium/wp-seo-premium.php' => [ 'Yoast SEO Premium', 'WPSEO_PREMIUM_VERSION' ], 'seo-by-rank-math/rank-math.php' => [ 'Rank Math SEO', 'RANK_MATH_VERSION' ], 'all-in-one-seo-pack/all_in_one_seo_pack.php' => [ 'All in One SEO', 'AIOSEO_VERSION' ], 'all-in-one-seo-pack-pro/all_in_one_seo_pack.php' => [ 'All in One SEO Pro', 'AIOSEO_VERSION' ], 'wp-seopress/seopress.php' => [ 'SEOPress', 'SEOPRESS_VERSION' ], 'autodescription/autodescription.php' => [ 'The SEO Framework', 'THE_SEO_FRAMEWORK_VERSION' ], 'squirrly-seo/squirrly.php' => [ 'Squirrly SEO', 'SQ_VERSION' ], 'smartcrawl-seo/wpmu-dev-seo.php' => [ 'SmartCrawl', 'SMARTCRAWL_VERSION' ], 'slim-seo/slim-seo.php' => [ 'Slim SEO', 'SLIM_SEO_VER' ], ]; public function __construct() { $settings = AI_Ready_WP::get_settings(); if ( empty( $settings['enable_seo'] ) ) { return; } // Meta box on posts/pages add_action( 'add_meta_boxes', [ $this, 'add_meta_box' ] ); add_action( 'save_post', [ $this, 'save_meta_box' ], 10, 2 ); // Output meta tags in add_action( 'wp_head', [ $this, 'output_meta_tags' ], 1 ); // Remove default WP title if we provide one add_filter( 'pre_get_document_title', [ $this, 'filter_title' ], 99 ); // XML Sitemap add_action( 'init', [ $this, 'register_sitemap_route' ] ); add_action( 'save_post', [ $this, 'invalidate_sitemap' ] ); add_action( 'delete_post', [ $this, 'invalidate_sitemap' ] ); } // ─── Coexistence with other SEO plugins ───────────────── /** * Name of the SEO plugin that is active next to this one, or ''. * * Until 4.4.2 the module printed its meta description, canonical, social * tags and Sitemap line regardless. On a site with The SEO Framework that * meant three meta descriptions in one head, ours first, and the same * Sitemap URL twice in robots.txt. Two plugins describing one page is not * twice the SEO, it is a page that contradicts itself. */ public static function competing_plugin(): string { $active = (array) get_option( 'active_plugins', [] ); if ( is_multisite() ) { $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) ); } $found = ''; foreach ( self::COMPETING_PLUGINS as $basename => [ $name, $marker ] ) { if ( in_array( $basename, $active, true ) || defined( $marker ) ) { $found = $name; break; } } /** * Filter the SEO plugin AI-Ready WP stands down for. * * Return a name to make the module stand down for a plugin it does * not know, or '' to keep it running next to one it does. * * @since 4.4.3 * * @param string $found Name of the detected plugin, or ''. */ return (string) apply_filters( 'airewp_competing_seo_plugin', $found ); } /** * Whether the module leaves titles, descriptions, social tags and the * sitemap to the other plugin. A checkbox in the settings overrides it. */ public static function yields(): bool { if ( '' === self::competing_plugin() ) { return false; } $settings = AI_Ready_WP::get_settings(); return empty( $settings['seo_force_with_plugin'] ); } /** * Whether this plugin is the one answering /sitemap.xml. * * robots.txt asks this before advertising the URL. Until 4.4.2 the line * was added even with the SEO module switched off, pointing crawlers at * a URL nothing served. */ public static function serves_sitemap( ?array $settings = null ): bool { $settings = $settings ?? AI_Ready_WP::get_settings(); return ! empty( $settings['enable_seo'] ) && ! self::yields(); } // ─── Meta Box ─────────────────────────────────────────── public function add_meta_box(): void { if ( self::yields() ) { return; } $post_types = get_post_types( [ 'public' => true ] ); foreach ( $post_types as $pt ) { add_meta_box( 'airewp_seo', __( 'SEO Settings', 'ai-ready-wp' ), [ $this, 'render_meta_box' ], $pt, 'normal', 'high' ); } } public function render_meta_box( \WP_Post $post ): void { wp_nonce_field( 'airewp_seo_save', 'airewp_seo_nonce' ); $title = get_post_meta( $post->ID, self::META_KEY_TITLE, true ); $desc = get_post_meta( $post->ID, self::META_KEY_DESC, true ); $keyword = get_post_meta( $post->ID, self::META_KEY_KEYWORD, true ); $noindex = get_post_meta( $post->ID, self::META_KEY_NOINDEX, true ); $nofollow = get_post_meta( $post->ID, self::META_KEY_NOFOLLOW, true ); $canonical = get_post_meta( $post->ID, self::META_KEY_CANONICAL, true ); ?>

post_title ); $color = $len > 60 ? '#d63638' : ( $len > 50 ? '#dba617' : '#00a32a' ); printf( '%d/60 %s', esc_attr( $color ), absint( $len ), esc_html__( 'characters. Recommended: 50-60.', 'ai-ready-wp' ) ); ?>

160 ? '#d63638' : ( $dlen > 120 ? '#dba617' : '#00a32a' ); printf( '%d/160 %s', esc_attr( $dcolor ), absint( $dlen ), esc_html__( 'characters. Recommended: 120-160.', 'ai-ready-wp' ) ); ?>

post_content ) : ?>
✓' : '✗', [ 'span' => [ 'style' => [] ] ] ); ?>
'airewp_seo_title', self::META_KEY_DESC => 'airewp_seo_desc', self::META_KEY_KEYWORD => 'airewp_seo_keyword', self::META_KEY_CANONICAL => 'airewp_seo_canonical', ]; foreach ( $fields as $meta_key => $input_key ) { $value = isset( $_POST[ $input_key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $input_key ] ) ) : ''; if ( $meta_key === self::META_KEY_CANONICAL && $value ) { $value = esc_url_raw( $value ); } update_post_meta( $post_id, $meta_key, $value ); } // Checkboxes update_post_meta( $post_id, self::META_KEY_NOINDEX, ! empty( $_POST['airewp_seo_noindex'] ) ? '1' : '' ); update_post_meta( $post_id, self::META_KEY_NOFOLLOW, ! empty( $_POST['airewp_seo_nofollow'] ) ? '1' : '' ); } // ─── Meta Tags Output ─────────────────────────────────── public function output_meta_tags(): void { if ( is_admin() || self::yields() ) { return; } $post = get_queried_object(); if ( ! $post instanceof \WP_Post ) { $this->output_home_meta(); return; } // Password-protected posts: this visitor has not unlocked the post, so // nothing derived from it may be printed here. The auto-generated // description used to run wp_trim_words() over $post->post_content and // published the protected text verbatim in description, og:description // and twitter:description — readable in the page source without the // password. A stored SEO description and the featured image are held // back as well: both can have been written while the post was still // public. Canonical and robots stay, they expose nothing. $protected = post_password_required( $post ); // A static front page is the site, not one page of it. Its post title // is usually "Home" or "Start", and until 4.4.2 that word was the // headline of every share of the site's own address, under og:type // "article". So on the front page the site name is the title, the // tagline the description, the home URL the canonical, and the type // is website. A title or description typed into the SEO box still // wins; that box is where an owner overrides exactly this. $is_front = is_front_page(); $settings = AI_Ready_WP::get_settings(); $title = get_post_meta( $post->ID, self::META_KEY_TITLE, true ); if ( ! $title ) { $title = $is_front ? get_bloginfo( 'name' ) : $post->post_title; } $desc = ''; if ( ! $protected ) { $desc = get_post_meta( $post->ID, self::META_KEY_DESC, true ); if ( ! $desc && $is_front ) { $desc = $settings['company_desc'] ?: get_bloginfo( 'description' ); } if ( ! $desc ) { // to_plain_text() instead of wp_strip_all_tags(): on a // page-builder site the latter published the raw shortcode // tree as the description, visible in Google and in every // social preview. $desc = wp_trim_words( AI_Ready_WP::to_plain_text( $post->post_content ), 25, '...' ); } } $canonical = get_post_meta( $post->ID, self::META_KEY_CANONICAL, true ); if ( ! $canonical ) { $canonical = $is_front ? home_url( '/' ) : get_permalink( $post->ID ); } $noindex = get_post_meta( $post->ID, self::META_KEY_NOINDEX, true ); $nofollow = get_post_meta( $post->ID, self::META_KEY_NOFOLLOW, true ); // Get OG image $image = $protected ? '' : get_the_post_thumbnail_url( $post->ID, 'large' ); if ( ! $image && ! $protected ) { $image = $settings['company_logo'] ?? ''; } // Meta description if ( $desc ) { printf( '' . "\n", esc_attr( $desc ) ); } // Canonical printf( '' . "\n", esc_url( $canonical ) ); // Robots $robots = []; if ( $noindex ) { $robots[] = 'noindex'; } if ( $nofollow ) { $robots[] = 'nofollow'; } if ( $robots ) { printf( '' . "\n", esc_attr( implode( ', ', $robots ) ) ); } // OpenGraph / Twitter — a social preview card for content nobody can // read is pointless, and og:title/og:image would leak the little that // is left. Locked posts stop here. if ( $protected ) { return; } // OpenGraph. The front page and the posts index are the site // itself; everything else is an article. $og_type = ( $is_front || is_home() ) ? 'website' : 'article'; printf( '' . "\n", esc_attr( $og_type ) ); printf( '' . "\n", esc_attr( $title ) ); if ( $desc ) { printf( '' . "\n", esc_attr( $desc ) ); } printf( '' . "\n", esc_url( $canonical ) ); if ( $image ) { printf( '' . "\n", esc_url( $image ) ); } printf( '' . "\n", esc_attr( get_bloginfo( 'name' ) ) ); // Twitter Card echo '' . "\n"; printf( '' . "\n", esc_attr( $title ) ); if ( $desc ) { printf( '' . "\n", esc_attr( $desc ) ); } if ( $image ) { printf( '' . "\n", esc_url( $image ) ); } } private function output_home_meta(): void { if ( ! is_front_page() && ! is_home() ) { return; } $settings = AI_Ready_WP::get_settings(); $title = $settings['company_name'] ?? get_bloginfo( 'name' ); $desc = $settings['company_desc'] ?? get_bloginfo( 'description' ); $url = home_url( '/' ); $image = $settings['company_logo'] ?? ''; if ( $desc ) { printf( '' . "\n", esc_attr( $desc ) ); } printf( '' . "\n", esc_url( $url ) ); echo '' . "\n"; printf( '' . "\n", esc_attr( $title ) ); if ( $desc ) { printf( '' . "\n", esc_attr( $desc ) ); } printf( '' . "\n", esc_url( $url ) ); if ( $image ) { printf( '' . "\n", esc_url( $image ) ); } } public function filter_title( string $title ): string { if ( is_admin() || self::yields() || ! is_singular() ) { return $title; } $post = get_queried_object(); if ( ! $post instanceof \WP_Post ) { return $title; } // Locked post: leave the document title to WordPress. The stored SEO // title is author-written text that can summarise what the post says // ("Q3 figures: the Contoso deal"), and it may well have been written // before the post was locked. Same reasoning as the description in // output_meta_tags(). WordPress' own title stays as it is — note that // single_post_title() does not add the "Protected:" prefix, that only // happens in get_the_title(), so this is not about keeping a marker. if ( post_password_required( $post ) ) { return $title; } $custom = get_post_meta( $post->ID, self::META_KEY_TITLE, true ); if ( $custom ) { return $custom . ' — ' . get_bloginfo( 'name' ); } return $title; } // ─── XML Sitemap ────────────────────────────────── public function register_sitemap_route(): void { // When the module stands down the other plugin's sitemap must be the // one that answers, and a rule of ours still sitting in the flushed // rewrite rules would keep pointing /sitemap.xml at a query variable // nobody handles. So the moment the state changes, flush once. $yields = self::yields(); if ( (bool) get_option( self::YIELDING_OPTION ) !== $yields ) { update_option( self::YIELDING_OPTION, $yields ? 1 : 0, true ); add_action( 'wp_loaded', 'flush_rewrite_rules' ); } if ( $yields ) { return; } add_rewrite_rule( '^sitemap\.xml$', 'index.php?airewp_sitemap=1', 'top' ); add_filter( 'query_vars', function ( array $vars ): array { $vars[] = 'airewp_sitemap'; return $vars; } ); add_action( 'template_redirect', [ $this, 'serve_sitemap' ] ); } public function serve_sitemap(): void { if ( ! get_query_var( 'airewp_sitemap' ) ) { return; } header( 'Content-Type: application/xml; charset=utf-8' ); header( 'X-Robots-Tag: noindex' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- every value inside is escaped in build_sitemap(). echo self::build_sitemap(); exit; } /** * Build the sitemap XML. * * Kept separate from serve_sitemap() so the result can be inspected without * headers and exit — that is what lets the test suite assert on the actual * XML instead of on how the query happens to be spelled. */ public static function build_sitemap(): string { ob_start(); echo '' . "\n"; echo '' . "\n"; // Homepage — skipped in the one case where it is a static front page // that carries a password. The URL itself gives nothing away, but the // rule "no locked entry in this sitemap" should hold without exceptions. if ( ! self::front_page_is_locked() ) { printf( " \n %s\n daily\n 1.0\n \n", esc_url( home_url( '/' ) ) ); } // Pages — 'has_password' => false keeps password-protected entries out // of the sitemap. Note this is stricter than WordPress core, which does // list protected posts in wp-sitemap.xml (verified in // WP_Sitemaps_Posts::get_posts_query_args(), WP 7.0). Submitting a URL // that only ever answers with a password form has no upside, so this // sitemap leaves them out. Filtering in SQL rather than in the loop also // keeps the posts_per_page budget intact. $pages = get_posts( [ 'post_type' => 'page', 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => 500, 'meta_query' => [ 'relation' => 'OR', [ 'key' => self::META_KEY_NOINDEX, 'compare' => 'NOT EXISTS', ], [ 'key' => self::META_KEY_NOINDEX, 'value' => '1', 'compare' => '!=', ], ], ] ); foreach ( $pages as $page ) { // Second gate, next to the value that is printed. The query above is // the real filter, but get_posts() does not suppress pre_get_posts, // so another plugin can rewrite the query and drop the condition. if ( '' !== (string) $page->post_password ) { continue; } printf( " \n %s\n %s\n weekly\n 0.8\n \n", esc_url( get_permalink( $page->ID ) ), esc_html( get_the_modified_date( 'c', $page ) ) ); } // Posts — see the note on the page query above. $posts = get_posts( [ 'post_type' => 'post', 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => 1000, 'meta_query' => [ 'relation' => 'OR', [ 'key' => self::META_KEY_NOINDEX, 'compare' => 'NOT EXISTS', ], [ 'key' => self::META_KEY_NOINDEX, 'value' => '1', 'compare' => '!=', ], ], ] ); foreach ( $posts as $p ) { // See the note in the page loop above. if ( '' !== (string) $p->post_password ) { continue; } printf( " \n %s\n %s\n monthly\n 0.6\n \n", esc_url( get_permalink( $p->ID ) ), esc_html( get_the_modified_date( 'c', $p ) ) ); } echo '' . "\n"; return (string) ob_get_clean(); } /** * Whether a static front page is set and carries a password. * * Reads the password off the post rather than asking * post_password_required(): the sitemap is the same document for every * caller, so whether this particular request happens to hold the cookie * must not change what it contains. */ private static function front_page_is_locked(): bool { if ( 'page' !== get_option( 'show_on_front' ) ) { return false; } $front_id = (int) get_option( 'page_on_front' ); if ( $front_id <= 0 ) { return false; } $front = get_post( $front_id ); return $front instanceof \WP_Post && '' !== (string) $front->post_password; } public function invalidate_sitemap(): void { delete_option( self::SITEMAP_OPTION ); } // ─── Focus Keyword Analysis ─────────────────────── /** * Analyze focus keyword placement. * * @return array */ public static function analyze_keyword( string $keyword, \WP_Post $post ): array { $kw_lower = mb_strtolower( $keyword ); $title = mb_strtolower( $post->post_title ); // Shortcode attributes are not prose. Counting them inflated the word // count and pushed the keyword density below the real value. $content = mb_strtolower( AI_Ready_WP::to_plain_text( $post->post_content ) ); $slug = $post->post_name; $meta_desc = mb_strtolower( get_post_meta( $post->ID, self::META_KEY_DESC, true ) ?: '' ); $word_count = str_word_count( $content ); $kw_count = mb_substr_count( $content, $kw_lower ); $density = $word_count > 0 ? ( $kw_count / $word_count ) * 100 : 0; return [ [ 'label' => __( 'Keyword in title', 'ai-ready-wp' ), 'pass' => str_contains( $title, $kw_lower ), ], [ 'label' => __( 'Keyword in URL slug', 'ai-ready-wp' ), 'pass' => str_contains( $slug, sanitize_title( $keyword ) ), ], [ 'label' => __( 'Keyword in meta description', 'ai-ready-wp' ), 'pass' => $meta_desc && str_contains( $meta_desc, $kw_lower ), ], [ 'label' => sprintf( /* translators: %s: density percentage */ __( 'Keyword density: %s%% (aim for 1-3%%)', 'ai-ready-wp' ), number_format( $density, 1 ) ), 'pass' => $density >= 0.5 && $density <= 4.0, ], [ 'label' => sprintf( /* translators: %d: word count */ __( 'Content length: %d words (min 300)', 'ai-ready-wp' ), $word_count ), 'pass' => $word_count >= 300, ], [ 'label' => __( 'Keyword in first paragraph', 'ai-ready-wp' ), 'pass' => self::keyword_in_first_paragraph( $kw_lower, $content ), ], ]; } private static function keyword_in_first_paragraph( string $kw, string $content ): bool { $first = mb_substr( $content, 0, 500 ); return str_contains( $first, $kw ); } }