[ '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 );
?>
'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 );
}
}