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' ] );
}
// ─── Meta Box ───────────────────────────────────────────
public function add_meta_box(): void {
$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() ) {
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 );
$title = get_post_meta( $post->ID, self::META_KEY_TITLE, true ) ?: $post->post_title;
$desc = $protected
? ''
: ( get_post_meta( $post->ID, self::META_KEY_DESC, true )
?: wp_trim_words( wp_strip_all_tags( $post->post_content ), 25, '...' ) );
$canonical = get_post_meta( $post->ID, self::META_KEY_CANONICAL, true ) ?: 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 ) {
$settings = AI_Ready_WP::get_settings();
$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
echo '' . "\n";
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() || ! 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 {
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 );
$content = mb_strtolower( wp_strip_all_tags( $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 );
}
}