manager = $manager;
$this->options = $manager->get_module_settings();
add_action( 'admin_init', [ $this, 'init' ] );
add_action( 'admin_post_adminoptim_seo_activate_module', [ $this, 'activate_recommended_module' ] );
add_action( 'admin_post_adminoptim_seo_refresh_health', [ $this, 'refresh_seo_health' ] );
}
/**
* Option keys that only the Pro settings UI writes.
*
* @return string[]
*/
protected function get_pro_only_option_keys(): array {
return [ 'enable_ai_suggestions', 'ai_include_focus_keyword', 'ai_include_secondary_keywords', 'ai_allow_replace_existing' ];
}
/**
* Start the engine running.
*
* @return void
*/
public function init() {
$this->settings_options = [
[
'option_group' => SEO_Manager::OPTION_NAME,
'option_name' => SEO_Manager::OPTION_NAME,
'args' => [ 'sanitize_callback' => [ $this, 'sanitize_settings' ] ],
],
[
'option_group' => SEO_Manager::SCHEMA_MENU_SLUG,
'option_name' => SEO_Manager::OPTION_NAME,
'args' => [ 'sanitize_callback' => [ $this, 'sanitize_settings' ] ],
],
];
$this->settings_sections = [
[
'id' => 'adminoptimizer-seo-manager-general',
'title' => __( 'General', 'admin-optimizer' ), // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- Pro intentionally shares the 'admin-optimizer' text domain with Free (see Text Domain header in admin-optimizer-pro.php); this is not a typo.
'description' => '',
'menu_slug' => SEO_Manager::OPTION_NAME,
'option_name' => SEO_Manager::OPTION_NAME,
'fields' => [
[
'type' => 'callback',
'title' => __( 'Supported Post Types', 'admin-optimizer' ),
'id' => 'enabled-post-types',
'name' => 'enabled_post_types',
'callback' => [ $this, 'render_post_type_checkboxes' ],
],
[
'type' => 'callback',
'title' => __( 'Post Type Search Visibility', 'admin-optimizer' ),
'id' => 'post-type-search-visibility',
'name' => 'noindex_post_types',
'callback' => [ $this, 'render_post_type_visibility_checkboxes' ],
],
[
'type' => 'callback',
'title' => __( 'Default Noindex Rules', 'admin-optimizer' ),
'id' => 'default-noindex',
'name' => 'default_noindex',
'callback' => [ $this, 'render_noindex_checkboxes' ],
],
],
],
[
'id' => 'adminoptimizer-seo-manager-title',
'title' => __( 'Title Settings', 'admin-optimizer' ),
'description' => $this->placeholder_help( [ '%title%', '%site_name%', '%tagline%', '%excerpt%', '%post_type%', '%date%', '%modified_date%', '%separator%' ] ) . ' ' . sprintf(
/* translators: %s: URL to the General Settings admin page. */
__( 'You can set the site name and tagline on the General Settings page.', 'admin-optimizer' ),
esc_url( admin_url( 'options-general.php' ) )
),
'menu_slug' => SEO_Manager::OPTION_NAME,
'option_name' => SEO_Manager::OPTION_NAME,
'fields' => [
[
'type' => 'select',
'title' => __( 'Title Separator', 'admin-optimizer' ),
'id' => 'title-separator',
'name' => 'title_separator',
'default' => SEO_Manager::get_default_settings()['title_separator'],
'choices' => [
'|' => '|',
'-' => '-',
'–' => '–',
'—' => '—',
'•' => '•',
':' => ':',
],
],
[
'type' => 'text',
'title' => __( 'Default Title Format', 'admin-optimizer' ),
'id' => 'title-format',
'name' => 'title_format',
'default' => SEO_Manager::get_default_settings()['title_format'],
'desc' => __( 'Used when a post does not have a custom meta title.', 'admin-optimizer' ),
],
[
'type' => 'text',
'title' => __( 'Homepage Title Format', 'admin-optimizer' ),
'id' => 'homepage-title-format',
'name' => 'homepage_title_format',
'default' => SEO_Manager::get_default_settings()['homepage_title_format'],
'desc' => __( 'Used for the homepage when it does not have a custom meta title.', 'admin-optimizer' ),
],
[
'type' => 'callback',
'title' => __( 'Title Format Per Post Type', 'admin-optimizer' ),
'id' => 'title-format-post-types',
'name' => 'title_format_post_types',
'callback' => [ $this, 'render_title_format_post_type_fields' ],
],
],
],
[
'id' => 'adminoptimizer-seo-manager-description',
'title' => __( 'Description Settings', 'admin-optimizer' ),
'description' => $this->placeholder_help( [ '%excerpt%', '%content_summary%', '%title%', '%site_name%', '%tagline%', '%post_type%', '%date%', '%modified_date%' ] ),
'menu_slug' => SEO_Manager::OPTION_NAME,
'option_name' => SEO_Manager::OPTION_NAME,
'fields' => [
[
'type' => 'textarea',
'title' => __( 'Default Description Format', 'admin-optimizer' ),
'id' => 'description-format',
'name' => 'description_format',
'default' => SEO_Manager::get_default_settings()['description_format'],
'desc' => __( 'Used when a post does not have a custom meta description.', 'admin-optimizer' ),
],
[
'type' => 'textarea',
'title' => __( 'Homepage Description Format', 'admin-optimizer' ),
'id' => 'homepage-description-format',
'name' => 'homepage_description_format',
'default' => SEO_Manager::get_default_settings()['homepage_description_format'],
'desc' => __( 'Used for the homepage when it does not have a custom meta description.', 'admin-optimizer' ),
],
[
'type' => 'select',
'title' => __( 'Description Fallback Source', 'admin-optimizer' ),
'id' => 'description-fallback',
'name' => 'description_fallback',
'default' => SEO_Manager::get_default_settings()['description_fallback'],
'choices' => [
'excerpt' => __( 'Excerpt', 'admin-optimizer' ),
'content' => __( 'Content', 'admin-optimizer' ),
'site_tagline' => __( 'Site tagline', 'admin-optimizer' ),
'empty' => __( 'Empty', 'admin-optimizer' ),
],
],
[
'type' => 'callback',
'title' => __( 'Description Format Per Post Type', 'admin-optimizer' ),
'id' => 'description-format-post-types',
'name' => 'description_format_post_types',
'callback' => [ $this, 'render_description_format_post_type_fields' ],
],
],
],
[
'id' => 'adminoptimizer-seo-manager-social',
'title' => __( 'Social Settings', 'admin-optimizer' ),
'description' => __( 'Social images load in this order: the manual per-post Facebook or X image, the post featured image, the first image in the post content, the corresponding global default below, then blank when no image is available.', 'admin-optimizer' ),
'menu_slug' => SEO_Manager::OPTION_NAME,
'option_name' => SEO_Manager::OPTION_NAME,
'fields' => [
[
'type' => 'callback',
'title' => __( 'Default Facebook Image', 'admin-optimizer' ),
'id' => 'default-facebook-image',
'name' => 'default_facebook_image',
'callback' => [ $this, 'render_media_url_field' ],
'desc' => __( 'Optional fallback for Facebook/Open Graph sharing cards when a post has no manual social image, featured image, or image in its content. Use a 1200x630 px image for best compatibility, with important text or logos centered.', 'admin-optimizer' ),
],
[
'type' => 'callback',
'title' => __( 'Default X Image', 'admin-optimizer' ),
'id' => 'default-x-image',
'name' => 'default_x_image',
'callback' => [ $this, 'render_media_url_field' ],
'desc' => __( 'Optional fallback for X/Twitter sharing cards when a post has no manual social image, featured image, or image in its content. Use 1200x630 px for a large summary card, with important content centered for cropping.', 'admin-optimizer' ),
],
],
],
[
'id' => 'adminoptimizer-seo-manager-canonical',
'title' => __( 'Canonical Settings', 'admin-optimizer' ),
'description' => '',
'menu_slug' => SEO_Manager::OPTION_NAME,
'option_name' => SEO_Manager::OPTION_NAME,
'fields' => [
[
'type' => 'checkbox',
'title' => __( 'Auto-generate Canonical URL', 'admin-optimizer' ),
'id' => 'auto-canonical',
'name' => 'auto_canonical',
'label' => __( 'Output the current singular permalink as canonical when no custom canonical URL is used.', 'admin-optimizer' ),
'default' => 1,
],
[
'type' => 'checkbox',
'title' => __( 'Allow Per-post Canonical Override', 'admin-optimizer' ),
'id' => 'allow-canonical-override',
'name' => 'allow_canonical_override',
'label' => __( 'Show the canonical URL field in the SEO metabox.', 'admin-optimizer' ),
'default' => 1,
],
],
],
[
'id' => 'adminoptimizer-seo-manager-schema',
'title' => __( 'Schema Settings', 'admin-optimizer' ),
'description' => __( 'Organization schema has no strictly required fields, but explicit name, website URL, and logo are recommended for production sites. If you choose LocalBusiness, address details are required for that schema type. Fields marked optional are only output when filled.', 'admin-optimizer' ),
'menu_slug' => SEO_Manager::SCHEMA_MENU_SLUG,
'option_name' => SEO_Manager::OPTION_NAME,
'fields' => [
[
'type' => 'checkbox',
'title' => __( 'Enable Schema Output', 'admin-optimizer' ),
'id' => 'enable-schema',
'name' => 'enable_schema',
'label' => __( 'Output automatic JSON-LD schema on singular public content.', 'admin-optimizer' ),
'desc' => __( 'Recommended. Disabling this stops SEO Manager from outputting Organization, WebPage, Article, and FAQ JSON-LD.', 'admin-optimizer' ),
'default' => 1,
],
[
'type' => 'select',
'title' => __( 'Organization Type', 'admin-optimizer' ),
'id' => 'organization-type',
'name' => 'organization_type',
'default' => SEO_Manager::get_default_settings()['organization_type'],
'choices' => [
'Organization' => __( 'Organization', 'admin-optimizer' ),
'LocalBusiness' => __( 'LocalBusiness', 'admin-optimizer' ),
'Person' => __( 'Person', 'admin-optimizer' ),
],
'desc' => __( 'Recommended. Choose the closest identity for the site owner: company/group, local physical business, or individual person.', 'admin-optimizer' ),
],
[
'type' => 'text',
'title' => __( 'Organization/Site Name', 'admin-optimizer' ),
'id' => 'organization-name',
'name' => 'organization_name',
'default' => '',
'desc' => __( 'Recommended. This is the public name shown in Organization schema; falls back to the WordPress site name when empty.', 'admin-optimizer' ),
],
[
'type' => 'text',
'title' => __( 'Alternate Name', 'admin-optimizer' ),
'id' => 'organization-alternate-name',
'name' => 'organization_alternate_name',
'default' => '',
'desc' => __( 'Optional. Use only for another common brand, business, or person name that people may search for.', 'admin-optimizer' ),
],
[
'type' => 'url',
'title' => __( 'Website URL', 'admin-optimizer' ),
'id' => 'organization-website-url',
'name' => 'organization_website_url',
'default' => '',
'desc' => __( 'Recommended. This should be the canonical homepage URL for the organization or person; falls back to the WordPress home URL when empty.', 'admin-optimizer' ),
],
[
'type' => 'callback',
'title' => __( 'Logo Image', 'admin-optimizer' ),
'id' => 'organization-logo',
'name' => 'organization_logo',
'callback' => [ $this, 'render_media_url_field' ],
'desc' => __( 'Recommended. Use the official logo image URL for Organization schema, not a social preview image. Google requires at least 112x112 px; a square PNG or SVG with good contrast on a white background is safest.', 'admin-optimizer' ),
],
[
'type' => 'callback',
'title' => __( 'Default Schema Image', 'admin-optimizer' ),
'id' => 'default-social-image',
'name' => 'default_social_image',
'callback' => [ $this, 'render_media_url_field' ],
'desc' => __( 'Optional. Used only as the JSON-LD schema image fallback when a post has no featured image. This does not replace the Default Facebook Image or Default X Image used for social sharing cards. A 1200x630 px image with important content centered is a safe default.', 'admin-optimizer' ),
],
[
'type' => 'url',
'title' => __( 'Facebook', 'admin-optimizer' ),
'id' => 'sameas-facebook',
'name' => 'sameas_facebook',
'default' => '',
'desc' => __( 'Optional. Enter the URL of the official Facebook profile or page for this organization/person. This is a sameAs profile link, not an image field.', 'admin-optimizer' ),
],
[
'type' => 'url',
'title' => __( 'X/Twitter', 'admin-optimizer' ),
'id' => 'sameas-x',
'name' => 'sameas_x',
'default' => '',
'desc' => __( 'Optional. Enter the URL of the official X/Twitter profile. This is a sameAs profile link, not an image field.', 'admin-optimizer' ),
],
[
'type' => 'url',
'title' => __( 'LinkedIn', 'admin-optimizer' ),
'id' => 'sameas-linkedin',
'name' => 'sameas_linkedin',
'default' => '',
'desc' => __( 'Optional. Enter the URL of the official LinkedIn company page or public profile. This is a sameAs profile link, not an image field.', 'admin-optimizer' ),
],
[
'type' => 'url',
'title' => __( 'Instagram', 'admin-optimizer' ),
'id' => 'sameas-instagram',
'name' => 'sameas_instagram',
'default' => '',
'desc' => __( 'Optional. Enter the URL of the official Instagram profile. This is a sameAs profile link, not an image field.', 'admin-optimizer' ),
],
[
'type' => 'url',
'title' => __( 'YouTube', 'admin-optimizer' ),
'id' => 'sameas-youtube',
'name' => 'sameas_youtube',
'default' => '',
'desc' => __( 'Optional. Enter the URL of the official YouTube channel or profile. This is a sameAs profile link, not an image field.', 'admin-optimizer' ),
],
[
'type' => 'url',
'title' => __( 'TikTok', 'admin-optimizer' ),
'id' => 'sameas-tiktok',
'name' => 'sameas_tiktok',
'default' => '',
'desc' => __( 'Optional. Enter the URL of the official TikTok profile. This is a sameAs profile link, not an image field.', 'admin-optimizer' ),
],
[
'type' => 'url',
'title' => __( 'Pinterest', 'admin-optimizer' ),
'id' => 'sameas-pinterest',
'name' => 'sameas_pinterest',
'default' => '',
'desc' => __( 'Optional. Enter the URL of the official Pinterest profile. This is a sameAs profile link, not an image field.', 'admin-optimizer' ),
],
[
'type' => 'email',
'title' => __( 'Contact Email', 'admin-optimizer' ),
'id' => 'contact-email',
'name' => 'contact_email',
'default' => '',
'desc' => __( 'Optional. Add the primary public contact email when it is appropriate to publish it in structured data.', 'admin-optimizer' ),
],
[
'type' => 'text',
'title' => __( 'Contact Phone', 'admin-optimizer' ),
'id' => 'contact-phone',
'name' => 'contact_phone',
'default' => '',
'desc' => __( 'Optional. Add the primary public phone number, ideally including country and area code.', 'admin-optimizer' ),
],
[
'type' => 'text',
'title' => __( 'Street Address', 'admin-optimizer' ),
'id' => 'address-street',
'name' => 'address_street',
'default' => '',
'desc' => __( 'Required for LocalBusiness; optional otherwise. Use the public street address for the organization location.', 'admin-optimizer' ),
],
[
'type' => 'text',
'title' => __( 'City', 'admin-optimizer' ),
'id' => 'address-city',
'name' => 'address_city',
'default' => '',
'desc' => __( 'Required for LocalBusiness; optional otherwise. Enter the city or locality for the public address.', 'admin-optimizer' ),
],
[
'type' => 'text',
'title' => __( 'Region/State', 'admin-optimizer' ),
'id' => 'address-region',
'name' => 'address_region',
'default' => '',
'desc' => __( 'Required for LocalBusiness when applicable; optional otherwise. Enter the state, province, county, or region.', 'admin-optimizer' ),
],
[
'type' => 'text',
'title' => __( 'Postal Code', 'admin-optimizer' ),
'id' => 'address-postal-code',
'name' => 'address_postal_code',
'default' => '',
'desc' => __( 'Required for LocalBusiness when applicable; optional otherwise. Enter the postal or ZIP code for the public address.', 'admin-optimizer' ),
],
[
'type' => 'text',
'title' => __( 'Country', 'admin-optimizer' ),
'id' => 'address-country',
'name' => 'address_country',
'default' => '',
'desc' => __( 'Required for LocalBusiness; optional otherwise. Use the country name or ISO two-letter country code, such as US, GB, AU, or SG.', 'admin-optimizer' ),
],
[
'type' => 'checkbox',
'title' => __( 'Enable Article Schema', 'admin-optimizer' ),
'id' => 'enable-article-schema',
'name' => 'enable_article_schema',
'label' => __( 'Include BlogPosting schema for posts in auto mode.', 'admin-optimizer' ),
'desc' => __( 'When enabled, posts using Auto schema type output BlogPosting schema. When disabled, posts using Auto schema type output WebPage schema instead. This does not affect posts manually set to Article.', 'admin-optimizer' ),
'default' => 1,
],
],
],
];
if ( $this->is_pro_active() ) {
$this->settings_sections[] = [
'id' => 'adminoptimizer-seo-manager-ai',
'title' => __( 'AI Settings', 'admin-optimizer' ),
'description' => '',
'menu_slug' => SEO_Manager::OPTION_NAME,
'option_name' => SEO_Manager::OPTION_NAME,
'fields' => [
[
'type' => 'checkbox',
'title' => __( 'Enable AI SEO Metadata Generation', 'admin-optimizer' ),
'id' => 'enable-ai-suggestions',
'name' => 'enable_ai_suggestions',
'label' => __( 'Show the Generate SEO Title and Meta Description button in the SEO metabox.', 'admin-optimizer' ),
'default' => 1,
],
[
'type' => 'checkbox',
'title' => __( 'Include Focus Keyword in AI Context', 'admin-optimizer' ),
'id' => 'ai-include-focus-keyword',
'name' => 'ai_include_focus_keyword',
'label' => __( 'Send the current focus keyword to the AI connector.', 'admin-optimizer' ),
'default' => 1,
],
[
'type' => 'checkbox',
'title' => __( 'Include Secondary Keywords in AI Context', 'admin-optimizer' ),
'id' => 'ai-include-secondary-keywords',
'name' => 'ai_include_secondary_keywords',
'label' => __( 'Send the post\'s secondary keywords to the AI connector. Only applies when secondary keywords are set on the post.', 'admin-optimizer' ),
'default' => 0,
],
[
'type' => 'checkbox',
'title' => __( 'Allow AI to Replace Existing Values Without Confirmation', 'admin-optimizer' ),
'id' => 'ai-allow-replace-existing',
'name' => 'ai_allow_replace_existing',
'label' => __( 'Apply AI-generated SEO metadata without asking before replacing non-empty SEO fields.', 'admin-optimizer' ),
'default' => 0,
],
],
];
} else {
$this->settings_sections[] = [
'id' => 'adminoptimizer-seo-manager-ai',
'title' => __( 'AI Settings', 'admin-optimizer' ),
/* translators: %1$s is the anchor link to the Pro version. %2$s is the closing anchor tag */
'description' => sprintf( __( 'Upgrade to the %1$sPro version%2$s to access AI-generated SEO metadata.', 'admin-optimizer' ), '', ' ' ),
'menu_slug' => SEO_Manager::OPTION_NAME,
'option_name' => SEO_Manager::OPTION_NAME,
'args' => [ 'before_section' => '
', 'after_section' => '
' ],
'fields' => [
[
'type' => 'checkbox',
'title' => __( 'Enable AI SEO Metadata Generation', 'admin-optimizer' ),
'id' => 'enable-ai-suggestions',
'name' => 'enable_ai_suggestions',
'label' => __( 'Show the Generate SEO Title and Meta Description button in the SEO metabox.', 'admin-optimizer' ),
'default' => 1,
'disabled' => 'disabled',
],
[
'type' => 'checkbox',
'title' => __( 'Include Focus Keyword in AI Context', 'admin-optimizer' ),
'id' => 'ai-include-focus-keyword',
'name' => 'ai_include_focus_keyword',
'label' => __( 'Send the current focus keyword to the AI connector.', 'admin-optimizer' ),
'default' => 1,
'disabled' => 'disabled',
],
[
'type' => 'checkbox',
'title' => __( 'Include Secondary Keywords in AI Context', 'admin-optimizer' ),
'id' => 'ai-include-secondary-keywords',
'name' => 'ai_include_secondary_keywords',
'label' => __( 'Send the post\'s secondary keywords to the AI connector. Only applies when secondary keywords are set on the post.', 'admin-optimizer' ),
'default' => 0,
'disabled' => 'disabled',
],
[
'type' => 'checkbox',
'title' => __( 'Allow AI to Replace Existing Values Without Confirmation', 'admin-optimizer' ),
'id' => 'ai-allow-replace-existing',
'name' => 'ai_allow_replace_existing',
'label' => __( 'Apply AI-generated SEO metadata without asking before replacing non-empty SEO fields.', 'admin-optimizer' ),
'default' => 0,
'disabled' => 'disabled',
],
],
];
}
$this->setup();
}
/**
* Render settings fields.
*
* The bundled settings helper falls back to a checkbox default when the saved
* value is empty, which makes intentionally disabled default-on checkboxes look
* enabled. Keep checkbox rendering strict for this module.
*
* @param array $args Field args.
*
* @return void
*/
public function render_field( $args ) {
$field = isset( $args['field'] ) && is_array( $args['field'] ) ? $args['field'] : [];
$type = isset( $field['type'] ) ? sanitize_key( $field['type'] ) : '';
if ( 'checkbox' !== $type ) {
parent::render_field( $args );
return;
}
$option_name = isset( $field['option_name'] ) ? $field['option_name'] : SEO_Manager::OPTION_NAME;
$option = isset( $field['option'] ) && is_array( $field['option'] ) ? $field['option'] : [];
$name = isset( $field['name'] ) ? sanitize_key( $field['name'] ) : '';
$id = isset( $field['id'] ) ? sanitize_key( $field['id'] ) : '';
$class = isset( $field['class'] ) ? sanitize_html_class( $field['class'] ) : '';
$label = isset( $field['label'] ) ? $field['label'] : '';
$desc = isset( $field['desc'] ) ? $field['desc'] : '';
$default = ! empty( $field['default'] ) ? 1 : 0;
$value = array_key_exists( $name, $option ) ? absint( $option[ $name ] ) : $default;
$disabled = ! empty( $field['disabled'] ) ? ' disabled="disabled"' : '';
echo ' ' . esc_html( $label ) . ' '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $disabled is a hardcoded safe string.
if ( $desc ) {
echo '' . wp_kses_post( $desc ) . '
';
}
}
/**
* Render settings page.
*
* @return void
*/
public function render_settings_page() {
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$is_health = '' === $tab || 'health' === $tab;
$is_general = 'general' === $tab;
$is_schema = 'schema' === $tab;
$is_automation = 'automation' === $tab;
$is_modules = 'modules' === $tab;
$admin_url = SEO_Manager::get_module_admin_url();
$menu_slug = $is_schema ? SEO_Manager::SCHEMA_MENU_SLUG : SEO_Manager::OPTION_NAME;
?>
render_seo_health_tab();
} elseif ( $is_modules ) {
$this->render_seo_modules_tab();
} elseif ( $is_automation ) {
$this->render_automation_tab();
} else {
$this->render_settings_on_page( $menu_slug );
}
?>
is_pro_active() ) {
do_action( 'adminoptim_seo_manager_render_automation_tab' );
return;
}
echo '';
$this->render_static_automation_card(
__( 'Generate SEO Title and Meta Description automatically', 'admin-optimizer' ),
__( 'Automatically fills in the SEO title and meta description in the background when content is published and both fields are empty.', 'admin-optimizer' )
);
$this->render_static_automation_card(
__( 'Content Performance Audit', 'admin-optimizer' ),
__( 'Analyzes your most-viewed content with AI and emails a review-only report. No content is changed automatically.', 'admin-optimizer' )
);
echo '
';
}
/**
* Render one non-interactive automation info card for Free.
*
* @param string $title Card title.
* @param string $description Card description.
*
* @return void
*/
private function render_static_automation_card( string $title, string $description ): void {
?>
get_seo_health_results();
$checks = isset( $results['checks'] ) && is_array( $results['checks'] ) ? $results['checks'] : [];
$status_count = isset( $results['status_count'] ) && is_array( $results['status_count'] ) ? $results['status_count'] : [];
$score = isset( $results['score'] ) ? absint( $results['score'] ) : 0;
$generated = isset( $results['generated_at'] ) ? absint( $results['generated_at'] ) : 0;
$refresh_url = wp_nonce_url(
add_query_arg(
[
'action' => 'adminoptim_seo_refresh_health',
],
admin_url( 'admin-post.php' )
),
'adminoptim_seo_refresh_health'
);
?>
'health', 'seo_health_refreshed' => 1 ] ) );
exit;
}
/**
* Get cached SEO Health results.
*
* @return array
*/
public function get_seo_health_results() {
$cached = get_transient( self::HEALTH_TRANSIENT_KEY );
if ( is_array( $cached ) ) {
return $cached;
}
$checks = $this->build_seo_health_checks();
$results = [
'checks' => $checks,
'status_count' => $this->count_health_statuses( $checks ),
'score' => $this->calculate_seo_health_score( $checks ),
'generated_at' => time(),
];
set_transient( self::HEALTH_TRANSIENT_KEY, $results, self::HEALTH_TRANSIENT_TTL );
return $results;
}
/**
* Build SEO Health checks.
*
* @return array
*/
private function build_seo_health_checks() {
$settings = $this->manager->get_module_settings();
$checks = [
$this->get_search_visibility_check(),
$this->get_homepage_title_check(),
$this->get_homepage_description_check(),
$this->get_homepage_noindex_check(),
$this->get_organization_schema_check( $settings ),
$this->get_social_defaults_check( $settings ),
$this->get_post_metadata_coverage_check(),
$this->get_duplicate_metadata_check(),
$this->get_seo_plugin_conflict_check(),
$this->get_xml_sitemap_check(),
$this->get_future_integrations_check(),
];
$checks = apply_filters( 'adminoptimizer_seo_health_checks', $checks );
return is_array( $checks ) ? array_values( array_filter( $checks, 'is_array' ) ) : [];
}
/**
* Get Search Engine Visibility check.
*
* @return array
*/
private function get_search_visibility_check() {
if ( '0' === (string) get_option( 'blog_public' ) ) {
return [
'id' => 'search_engine_visibility',
'label' => __( 'Search Engine Visibility', 'admin-optimizer' ),
'status' => 'warning',
'message' => __( 'WordPress is currently discouraging search engines from indexing this site. This is useful for staging or development sites, but should usually be disabled on production sites.', 'admin-optimizer' ),
'action_url' => admin_url( 'options-reading.php' ),
'action_text' => __( 'Open Reading Settings', 'admin-optimizer' ),
];
}
return [
'id' => 'search_engine_visibility',
'label' => __( 'Search Engine Visibility', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'WordPress is allowing search engines to index this site.', 'admin-optimizer' ),
];
}
/**
* Get homepage title check.
*
* @return array
*/
private function get_homepage_title_check() {
$homepage_id = $this->get_static_homepage_id();
if ( ! $homepage_id ) {
$settings = $this->manager->get_module_settings();
return [
'id' => 'homepage_seo_title',
'label' => __( 'Homepage SEO Title', 'admin-optimizer' ),
'status' => ! empty( $settings['homepage_title_format'] ) ? 'pass' : 'warning',
'message' => ! empty( $settings['homepage_title_format'] )
? __( 'The latest-posts homepage uses the configured homepage title format.', 'admin-optimizer' )
: __( 'The latest-posts homepage does not have a homepage title format configured.', 'admin-optimizer' ),
'action_url' => SEO_Manager::get_module_admin_url(),
'action_text' => __( 'Open SEO Settings', 'admin-optimizer' ),
];
}
$settings = $this->manager->get_module_settings();
$seo_data = $this->manager->get_post_seo_data( $homepage_id );
if ( empty( $seo_data['meta_title'] ) ) {
if ( ! empty( $settings['homepage_title_format'] ) ) {
return [
'id' => 'homepage_seo_title',
'label' => __( 'Homepage SEO Title', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'The static homepage uses the configured homepage title format.', 'admin-optimizer' ),
];
}
return [
'id' => 'homepage_seo_title',
'label' => __( 'Homepage SEO Title', 'admin-optimizer' ),
'status' => 'warning',
'message' => __( 'The static homepage does not have a custom SEO title configured.', 'admin-optimizer' ),
'action_url' => get_edit_post_link( $homepage_id, '' ),
'action_text' => __( 'Edit Homepage', 'admin-optimizer' ),
];
}
return [
'id' => 'homepage_seo_title',
'label' => __( 'Homepage SEO Title', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'The static homepage has a custom SEO title configured.', 'admin-optimizer' ),
];
}
/**
* Get homepage description check.
*
* @return array
*/
private function get_homepage_description_check() {
$homepage_id = $this->get_static_homepage_id();
if ( ! $homepage_id ) {
$settings = $this->manager->get_module_settings();
return [
'id' => 'homepage_meta_description',
'label' => __( 'Homepage Meta Description', 'admin-optimizer' ),
'status' => ! empty( $settings['homepage_description_format'] ) ? 'pass' : 'warning',
'message' => ! empty( $settings['homepage_description_format'] )
? __( 'The latest-posts homepage uses the configured homepage description format.', 'admin-optimizer' )
: __( 'The latest-posts homepage does not have a homepage description format configured.', 'admin-optimizer' ),
'action_url' => SEO_Manager::get_module_admin_url(),
'action_text' => __( 'Open SEO Settings', 'admin-optimizer' ),
];
}
$settings = $this->manager->get_module_settings();
$seo_data = $this->manager->get_post_seo_data( $homepage_id );
if ( empty( $seo_data['meta_description'] ) ) {
if ( ! empty( $settings['homepage_description_format'] ) ) {
return [
'id' => 'homepage_meta_description',
'label' => __( 'Homepage Meta Description', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'The static homepage uses the configured homepage description format.', 'admin-optimizer' ),
];
}
return [
'id' => 'homepage_meta_description',
'label' => __( 'Homepage Meta Description', 'admin-optimizer' ),
'status' => 'warning',
'message' => __( 'The static homepage does not have a custom meta description configured.', 'admin-optimizer' ),
'action_url' => get_edit_post_link( $homepage_id, '' ),
'action_text' => __( 'Edit Homepage', 'admin-optimizer' ),
];
}
return [
'id' => 'homepage_meta_description',
'label' => __( 'Homepage Meta Description', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'The static homepage has a custom meta description configured.', 'admin-optimizer' ),
];
}
/**
* Get homepage noindex check.
*
* @return array
*/
private function get_homepage_noindex_check() {
$homepage_id = $this->get_static_homepage_id();
if ( ! $homepage_id ) {
return [
'id' => 'homepage_noindex',
'label' => __( 'Homepage Noindex', 'admin-optimizer' ),
'status' => 'info',
'message' => __( 'This site uses the latest posts as the homepage, so there is no static homepage noindex setting to check.', 'admin-optimizer' ),
];
}
$seo_data = $this->manager->get_post_seo_data( $homepage_id );
if ( ! empty( $seo_data['noindex'] ) ) {
return [
'id' => 'homepage_noindex',
'label' => __( 'Homepage Noindex', 'admin-optimizer' ),
'status' => 'warning',
'message' => __( 'The static homepage is marked noindex in SEO Manager. This may be intentional for staging sites, but production homepages are usually indexable.', 'admin-optimizer' ),
'action_url' => get_edit_post_link( $homepage_id, '' ),
'action_text' => __( 'Edit Homepage', 'admin-optimizer' ),
];
}
return [
'id' => 'homepage_noindex',
'label' => __( 'Homepage Noindex', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'The static homepage is not marked noindex in SEO Manager.', 'admin-optimizer' ),
];
}
/**
* Get Organization schema check.
*
* @param array $settings Module settings.
*
* @return array
*/
private function get_organization_schema_check( $settings ) {
$missing = [];
if ( empty( $settings['organization_name'] ) ) {
$missing[] = __( 'Organization name', 'admin-optimizer' );
}
if ( empty( $settings['organization_website_url'] ) ) {
$missing[] = __( 'Organization URL', 'admin-optimizer' );
}
if ( empty( $settings['organization_logo'] ) ) {
$missing[] = __( 'Organization logo', 'admin-optimizer' );
}
if ( ! empty( $missing ) ) {
return [
'id' => 'organization_schema',
'label' => __( 'Organization Schema', 'admin-optimizer' ),
'status' => 'warning',
'message' => __( 'Some organization schema details are not configured. SEO Manager has fallbacks for name and URL, but explicit values are clearer for production sites.', 'admin-optimizer' ),
'details' => array_map(
static function ( $label ) {
return sprintf(
/* translators: %s: missing field label. */
__( 'Missing: %s', 'admin-optimizer' ),
$label
);
},
$missing
),
'action_url' => SEO_Manager::get_module_admin_url( [ 'tab' => 'schema' ] ),
'action_text' => __( 'Open Schema Settings', 'admin-optimizer' ),
];
}
return [
'id' => 'organization_schema',
'label' => __( 'Organization Schema', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'Organization name, URL, and logo are configured.', 'admin-optimizer' ),
];
}
/**
* Get social defaults check.
*
* @param array $settings Module settings.
*
* @return array
*/
private function get_social_defaults_check( $settings ) {
$missing = [];
if ( empty( $settings['default_facebook_image'] ) ) {
$missing[] = __( 'Default Facebook/Open Graph image', 'admin-optimizer' );
}
if ( empty( $settings['default_x_image'] ) ) {
$missing[] = __( 'Default X/Twitter image', 'admin-optimizer' );
}
if ( ! empty( $missing ) ) {
return [
'id' => 'social_defaults',
'label' => __( 'Social Defaults', 'admin-optimizer' ),
'status' => 'warning',
'message' => __( 'Default social images provide a final fallback when manual, featured, and content images are unavailable.', 'admin-optimizer' ),
'details' => array_map(
static function ( $label ) {
return sprintf(
/* translators: %s: missing field label. */
__( 'Missing: %s', 'admin-optimizer' ),
$label
);
},
$missing
),
'action_url' => SEO_Manager::get_module_admin_url( [ 'tab' => 'general' ] ),
'action_text' => __( 'Open Social Settings', 'admin-optimizer' ),
];
}
return [
'id' => 'social_defaults',
'label' => __( 'Social Defaults', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'Default Facebook/Open Graph and X/Twitter images are configured.', 'admin-optimizer' ),
];
}
/**
* Get post metadata coverage check.
*
* @return array
*/
private function get_post_metadata_coverage_check() {
$coverage = $this->get_post_metadata_coverage_counts();
$details = [];
$total_missing_titles = 0;
$total_missing_descriptions = 0;
$total_noindex = 0;
foreach ( $coverage as $post_type => $counts ) {
$post_type_object = get_post_type_object( $post_type );
$type_label = $post_type_object ? $post_type_object->labels->name : $post_type;
$total_missing_titles += $counts['missing_titles'];
$total_missing_descriptions += $counts['missing_descriptions'];
$total_noindex += $counts['noindex'];
$details[] = sprintf(
/* translators: 1: post type label, 2: default title count, 3: default description count, 4: noindex count. */
__( '%1$s: %2$d using default titles, %3$d using default descriptions, %4$d marked noindex.', 'admin-optimizer' ),
$type_label,
$counts['missing_titles'],
$counts['missing_descriptions'],
$counts['noindex']
);
}
if ( $total_missing_titles || $total_missing_descriptions ) {
return [
'id' => 'post_metadata_coverage',
'label' => __( 'Post Metadata Coverage', 'admin-optimizer' ),
'status' => 'info',
'message' => __( 'Some published content is using the default SEO title or description formats. This can be intentional when your defaults are well configured, but important pages should be reviewed for custom metadata. Noindex counts are shown for visibility only and do not reduce the score.', 'admin-optimizer' ),
'details' => $details,
'action_url' => admin_url( 'edit.php' ),
'action_text' => __( 'Open Content List', 'admin-optimizer' ),
];
}
if ( $total_noindex ) {
return [
'id' => 'post_metadata_coverage',
'label' => __( 'Post Metadata Coverage', 'admin-optimizer' ),
'status' => 'info',
'message' => __( 'Published content has custom SEO metadata. Some published items are marked noindex, which may be intentional and does not reduce the score.', 'admin-optimizer' ),
'details' => $details,
];
}
return [
'id' => 'post_metadata_coverage',
'label' => __( 'Post Metadata Coverage', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'Enabled published content has custom SEO titles and descriptions, and none are marked noindex.', 'admin-optimizer' ),
'details' => $details,
];
}
/**
* Get duplicate metadata check.
*
* @return array
*/
private function get_duplicate_metadata_check() {
$duplicates = $this->get_duplicate_metadata_counts();
if ( $duplicates['title_groups'] || $duplicates['description_groups'] ) {
return [
'id' => 'duplicate_metadata',
'label' => __( 'Duplicate Metadata', 'admin-optimizer' ),
'status' => 'warning',
'message' => __( 'Some published posts share the same custom SEO title or meta description. Duplicate metadata can be intentional, but unique values are usually clearer for search results.', 'admin-optimizer' ),
'details' => [
sprintf(
/* translators: %d: duplicate title group count. */
__( 'Duplicate custom SEO title groups: %d', 'admin-optimizer' ),
$duplicates['title_groups']
),
sprintf(
/* translators: %d: duplicate description group count. */
__( 'Duplicate custom meta description groups: %d', 'admin-optimizer' ),
$duplicates['description_groups']
),
],
];
}
return [
'id' => 'duplicate_metadata',
'label' => __( 'Duplicate Metadata', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'No duplicate custom SEO titles or meta descriptions were found among published enabled post types.', 'admin-optimizer' ),
];
}
/**
* Get SEO plugin conflict check.
*
* @return array
*/
private function get_seo_plugin_conflict_check() {
$detected = $this->get_detected_seo_plugins();
if ( ! empty( $detected ) ) {
return [
'id' => 'seo_plugin_conflict',
'label' => __( 'SEO Plugin Conflict', 'admin-optimizer' ),
'status' => 'warning',
'message' => __( 'Another SEO plugin appears to be active. This may output duplicate SEO tags if both plugins manage titles, descriptions, social tags, or schema.', 'admin-optimizer' ),
'details' => $detected,
];
}
return [
'id' => 'seo_plugin_conflict',
'label' => __( 'SEO Plugin Conflict', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'No common competing SEO plugins were detected as active.', 'admin-optimizer' ),
];
}
/**
* Get XML Sitemap module check.
*
* @return array
*/
private function get_xml_sitemap_check() {
if ( ! $this->is_recommended_module_active( 'enable_xml_sitemap' ) ) {
return [
'id' => 'xml_sitemap_module',
'label' => __( 'XML Sitemap Module', 'admin-optimizer' ),
'status' => 'info',
'message' => __( 'The Admin Optimizer XML Sitemap module is not active. WordPress core or another SEO plugin may still provide a sitemap, but SEO Health cannot confirm that from this module.', 'admin-optimizer' ),
'action_url' => SEO_Manager::get_module_admin_url( [ 'tab' => 'modules' ] ),
'action_text' => __( 'Open SEO Modules', 'admin-optimizer' ),
];
}
$options = get_option( 'adminoptim_xml_sitemap', [] );
$options = is_array( $options ) ? $options : [];
$sitemap_url = ! empty( $options['custom_sitemap_slug'] ) ? home_url( '/' . sanitize_title( $options['custom_sitemap_slug'] ) . '.xml' ) : home_url( '/wp-sitemap.xml' );
$details = [
sprintf(
/* translators: %s: sitemap URL. */
__( 'Sitemap URL: %s', 'admin-optimizer' ),
$sitemap_url
),
empty( $options['include_in_robotstxt'] )
? __( 'Sitemap link in robots.txt is not enabled in XML Sitemap settings.', 'admin-optimizer' )
: __( 'Sitemap link in robots.txt is enabled in XML Sitemap settings.', 'admin-optimizer' ),
];
return [
'id' => 'xml_sitemap_module',
'label' => __( 'XML Sitemap Module', 'admin-optimizer' ),
'status' => 'pass',
'message' => __( 'The Admin Optimizer XML Sitemap module is active.', 'admin-optimizer' ),
'details' => $details,
'action_url' => Module_Registry::get_module_url( 'xml_sitemap' ),
'action_text' => __( 'Open XML Sitemap Settings', 'admin-optimizer' ),
];
}
/**
* Placeholder check for future cross-module integrations.
*
* @return array
*/
private function get_future_integrations_check() {
return [
'id' => 'future_integrations',
'label' => __( 'Future SEO Integrations', 'admin-optimizer' ),
'status' => 'info',
'message' => __( 'Robots.txt reachability, redirect, and content freshness checks can be added through the SEO Health checks filter as those module APIs become available.', 'admin-optimizer' ),
];
}
/**
* Get static homepage ID, if the site uses one.
*
* @return int
*/
private function get_static_homepage_id() {
if ( 'page' !== get_option( 'show_on_front' ) ) {
return 0;
}
return absint( get_option( 'page_on_front' ) );
}
/**
* Count metadata coverage for enabled post types.
*
* @return array
*/
private function get_post_metadata_coverage_counts() {
$settings = $this->manager->get_module_settings();
$post_types = $this->manager->get_supported_post_types();
$noindex_post_types = isset( $settings['noindex_post_types'] ) && is_array( $settings['noindex_post_types'] ) ? $settings['noindex_post_types'] : [];
$coverage = [];
foreach ( $post_types as $post_type ) {
$coverage[ $post_type ] = [
'missing_titles' => $this->count_posts_missing_seo_field( $post_type, 'meta_title' ),
'missing_descriptions' => $this->count_posts_missing_seo_field( $post_type, 'meta_description' ),
'noindex' => in_array( $post_type, $noindex_post_types, true ) ? $this->count_published_posts_for_type( $post_type ) : $this->count_posts_with_noindex( $post_type ),
];
}
return $coverage;
}
/**
* Count published posts for a post type.
*
* @param string $post_type Post type.
*
* @return int
*/
private function count_published_posts_for_type( $post_type ) {
global $wpdb;
$sql = "
SELECT COUNT(1)
FROM {$wpdb->posts}
WHERE post_status = 'publish'
AND post_type = %s
";
return absint( $wpdb->get_var( $wpdb->prepare( $sql, $post_type ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Count published posts missing a custom SEO field.
*
* @param string $post_type Post type.
* @param string $field SEO meta field.
*
* @return int
*/
private function count_posts_missing_seo_field( $post_type, $field ) {
global $wpdb;
$field_lengths = [
'meta_title' => 10,
'meta_description' => 16,
];
if ( empty( $field_lengths[ $field ] ) ) {
return 0;
}
$pattern = 's:' . absint( $field_lengths[ $field ] ) . ':"' . $field . '";s:[1-9][0-9]*:"';
$sql = "
SELECT COUNT(1)
FROM {$wpdb->posts} p
LEFT JOIN {$wpdb->postmeta} pm
ON p.ID = pm.post_id
AND pm.meta_key = %s
WHERE p.post_status = 'publish'
AND p.post_type = %s
AND (pm.post_id IS NULL OR pm.meta_value NOT REGEXP %s)
";
return absint( $wpdb->get_var( $wpdb->prepare( $sql, SEO_Manager::META_KEY, $post_type, $pattern ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Count published posts marked noindex in SEO Manager meta.
*
* @param string $post_type Post type.
*
* @return int
*/
private function count_posts_with_noindex( $post_type ) {
global $wpdb;
$pattern = 's:7:"noindex";(i:1|b:1|s:1:"1")';
$sql = "
SELECT COUNT(1)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm
ON p.ID = pm.post_id
AND pm.meta_key = %s
WHERE p.post_status = 'publish'
AND p.post_type = %s
AND pm.meta_value REGEXP %s
";
return absint( $wpdb->get_var( $wpdb->prepare( $sql, SEO_Manager::META_KEY, $post_type, $pattern ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Count duplicate custom SEO titles and descriptions.
*
* @return array
*/
private function get_duplicate_metadata_counts() {
global $wpdb;
$post_types = $this->manager->get_supported_post_types();
if ( empty( $post_types ) ) {
return [
'title_groups' => 0,
'description_groups' => 0,
];
}
$placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
$sql = "
SELECT pm.meta_value
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm
ON p.ID = pm.post_id
AND pm.meta_key = %s
WHERE p.post_status = 'publish'
AND p.post_type IN ($placeholders)
";
$args = array_merge( [ SEO_Manager::META_KEY ], $post_types );
$meta_values = $wpdb->get_col( $wpdb->prepare( $sql, $args ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$titles = [];
$descriptions = [];
foreach ( $meta_values as $meta_value ) {
$seo_data = maybe_unserialize( $meta_value );
if ( ! is_array( $seo_data ) ) {
continue;
}
$title = isset( $seo_data['meta_title'] ) ? trim( wp_strip_all_tags( (string) $seo_data['meta_title'] ) ) : '';
if ( '' !== $title ) {
$title_key = strtolower( $title );
$titles[ $title_key ] = isset( $titles[ $title_key ] ) ? $titles[ $title_key ] + 1 : 1;
}
$description = isset( $seo_data['meta_description'] ) ? trim( wp_strip_all_tags( (string) $seo_data['meta_description'] ) ) : '';
if ( '' !== $description ) {
$description_key = strtolower( $description );
$descriptions[ $description_key ] = isset( $descriptions[ $description_key ] ) ? $descriptions[ $description_key ] + 1 : 1;
}
}
return [
'title_groups' => count( array_filter( $titles, [ $this, 'is_duplicate_count' ] ) ),
'description_groups' => count( array_filter( $descriptions, [ $this, 'is_duplicate_count' ] ) ),
];
}
/**
* Check whether a count represents a duplicate group.
*
* @param int $count Count.
*
* @return bool
*/
private function is_duplicate_count( $count ) {
return $count > 1;
}
/**
* Get active common SEO plugins.
*
* @return array
*/
private function get_detected_seo_plugins() {
$plugins = [
'wordpress-seo/wp-seo.php' => __( 'Yoast SEO', 'admin-optimizer' ),
'wordpress-seo-premium/wp-seo-premium.php' => __( 'Yoast SEO Premium', 'admin-optimizer' ),
'seo-by-rank-math/rank-math.php' => __( 'Rank Math SEO', 'admin-optimizer' ),
'seo-by-rank-math-pro/rank-math-pro.php' => __( 'Rank Math SEO Pro', 'admin-optimizer' ),
'all-in-one-seo-pack/all_in_one_seo_pack.php' => __( 'All in One SEO', 'admin-optimizer' ),
'all-in-one-seo-pack-pro/all_in_one_seo_pack.php' => __( 'All in One SEO Pro', 'admin-optimizer' ),
'wp-seopress/seopress.php' => __( 'SEOPress', 'admin-optimizer' ),
'wp-seopress-pro/seopress-pro.php' => __( 'SEOPress Pro', 'admin-optimizer' ),
'autodescription/autodescription.php' => __( 'The SEO Framework', 'admin-optimizer' ),
'slim-seo/slim-seo.php' => __( 'Slim SEO', 'admin-optimizer' ),
];
$active_plugins = get_option( 'active_plugins', [] );
$active_plugins = is_array( $active_plugins ) ? $active_plugins : [];
if ( is_multisite() ) {
$network_plugins = get_site_option( 'active_sitewide_plugins', [] );
$network_plugins = is_array( $network_plugins ) ? array_keys( $network_plugins ) : [];
$active_plugins = array_merge( $active_plugins, $network_plugins );
}
$detected = [];
foreach ( $plugins as $plugin_file => $plugin_label ) {
if ( in_array( $plugin_file, $active_plugins, true ) ) {
$detected[] = $plugin_label;
}
}
return $detected;
}
/**
* Count check statuses.
*
* @param array $checks Checks.
*
* @return array
*/
private function count_health_statuses( $checks ) {
$count = [
'pass' => 0,
'warning' => 0,
'critical' => 0,
'info' => 0,
];
foreach ( $checks as $check ) {
$status = isset( $check['status'] ) ? sanitize_key( $check['status'] ) : 'info';
if ( ! isset( $count[ $status ] ) ) {
$status = 'info';
}
++$count[ $status ];
}
return $count;
}
/**
* Calculate SEO Health score.
*
* @param array $checks Checks.
*
* @return int
*/
private function calculate_seo_health_score( $checks ) {
$score = 100;
foreach ( $checks as $check ) {
$status = isset( $check['status'] ) ? sanitize_key( $check['status'] ) : 'info';
if ( 'critical' === $status ) {
$score -= 20;
} elseif ( 'warning' === $status ) {
$score -= 8;
}
}
return max( 0, min( 100, $score ) );
}
/**
* Get display label for a health status.
*
* @param string $status Status.
*
* @return string
*/
private function get_health_status_label( $status ) {
$labels = [
'pass' => __( 'Pass', 'admin-optimizer' ),
'warning' => __( 'Warning', 'admin-optimizer' ),
'critical' => __( 'Critical', 'admin-optimizer' ),
'info' => __( 'Info', 'admin-optimizer' ),
];
return isset( $labels[ $status ] ) ? $labels[ $status ] : $labels['info'];
}
/**
* Render related SEO modules tab.
*
* @return void
*/
private function render_seo_modules_tab() {
?>
get_recommended_seo_modules() as $module ) : ?>
is_recommended_module_active( $module['module_id'] ); ?>
__( 'Robots.txt & Ads.txt Manager', 'admin-optimizer' ),
'module_id' => 'ads_robots_txt',
'description' => __( 'Manage robots.txt directives alongside SEO indexing rules.', 'admin-optimizer' ),
'settings_url' => Module_Registry::get_module_url( 'ads_robots_txt' ),
],
[
'title' => __( 'XML Sitemap & Google News Sitemap', 'admin-optimizer' ),
'module_id' => 'xml_sitemap',
'description' => __( 'Publish sitemap URLs and exclude noindex content from search discovery.', 'admin-optimizer' ),
'settings_url' => Module_Registry::get_module_url( 'xml_sitemap' ),
],
[
'title' => __( 'Lock Post Modified Date', 'admin-optimizer' ),
'module_id' => 'modified_date',
'description' => __( 'Control modified dates for content freshness signals and editorial workflows.', 'admin-optimizer' ),
'settings_url' => Module_Registry::get_module_url( 'modified_date' ),
],
[
'title' => __( 'Redirect Manager', 'admin-optimizer' ),
'module_id' => 'redirect_manager',
'description' => __( 'Create and monitor redirects for moved content and SEO cleanup.', 'admin-optimizer' ),
'settings_url' => Module_Registry::get_module_url( 'redirect_manager' ),
],
];
}
/**
* Check whether a recommended module is active.
*
* @param string $module_id Canonical module ID.
*
* @return bool
*/
private function is_recommended_module_active( $module_id ) {
return Module_Registry::is_enabled( sanitize_key( $module_id ) );
}
/**
* Get activation URL for a recommended module.
*
* @param string $module_id Canonical module ID.
*
* @return string
*/
private function get_recommended_module_activation_url( $module_id ) {
$module_id = sanitize_key( $module_id );
return wp_nonce_url(
add_query_arg(
[
'action' => 'adminoptim_seo_activate_module',
'module' => $module_id,
],
admin_url( 'admin-post.php' )
),
'adminoptim_seo_activate_module_' . $module_id
);
}
/**
* Activate a recommended module from the SEO Modules tab.
*
* @return void
*/
public function activate_recommended_module() {
$module_key = isset( $_GET['module'] ) ? sanitize_key( wp_unslash( $_GET['module'] ) ) : '';
$allowed_keys = wp_list_pluck( $this->get_recommended_seo_modules(), 'module_id' );
if ( ! in_array( $module_key, $allowed_keys, true ) ) {
wp_safe_redirect( SEO_Manager::get_module_admin_url( [ 'tab' => 'modules' ] ) );
exit;
}
$module = Module_Registry::get_module( $module_key );
if ( null === $module || ! current_user_can( Module_Registry::get_activation_capability( $module['id'] ) ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to activate modules.', 'admin-optimizer' ) );
}
check_admin_referer( 'adminoptim_seo_activate_module_' . $module_key );
Module_Registry::set_enabled( $module['id'], true );
wp_safe_redirect( SEO_Manager::get_module_admin_url( [ 'tab' => 'modules' ] ) );
exit;
}
/**
* Enqueue settings-page assets.
*
* @param string $hook_suffix Hook suffix.
*
* @return void
*/
public function enqueue_settings_assets( string $hook_suffix ): void {
wp_enqueue_media();
wp_enqueue_script( 'jquery' );
wp_enqueue_style( 'adminoptim-modules-pro-settings' );
wp_add_inline_script(
'jquery',
'jQuery(function($){$(document).on("click",".adminoptim-seo-settings-media-button",function(e){var target=$(this).data("target"),$input=$("#"+target),frame;e.preventDefault();if(!$input.length||typeof wp==="undefined"||!wp.media){return;}frame=wp.media({title:"' . esc_js( __( 'Select Image', 'admin-optimizer' ) ) . '",button:{text:"' . esc_js( __( 'Use this image', 'admin-optimizer' ) ) . '"},multiple:false,library:{type:"image"}});frame.on("select",function(){var attachment=frame.state().get("selection").first().toJSON();if(attachment.url){$input.val(attachment.url).trigger("change");}});frame.open();});$(document).on("click",".adminoptim-seo-settings-media-remove",function(e){e.preventDefault();$("#"+$(this).data("target")).val("").trigger("change");});});'
);
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( '' === $tab || 'health' === $tab ) {
wp_enqueue_style( 'adminoptimizer-seo-health', SEO_Manager::MODULE_URL . 'assets/css/seo-health.min.css', [], ADMINOPTIMIZER_VERSION );
}
if ( 'automation' === $tab ) {
wp_enqueue_style( 'adminoptimizer-seo-automation-card', SEO_Manager::MODULE_URL . 'assets/css/automation-rule-card.min.css', [], ADMINOPTIMIZER_VERSION );
/**
* Fires while the Automation tab's own assets are being enqueued
* (this method is invoked correctly for the current screen/tab by
* Section_Renderer; a generic admin_enqueue_scripts hook cannot
* reliably re-derive that same screen check). Pro uses this to
* enqueue its interactive accordion script only when active.
*/
do_action( 'adminoptim_seo_manager_enqueue_automation_assets' );
}
}
/**
* Render supported post type checkboxes.
*
* @return void
*/
public function render_post_type_checkboxes() {
$settings = $this->manager->get_module_settings();
$selected = isset( $settings['enabled_post_types'] ) && is_array( $settings['enabled_post_types'] ) ? $settings['enabled_post_types'] : [];
$post_types = get_post_types( [ 'public' => true ], 'objects' );
$allowed = $this->manager->get_available_post_type_names();
foreach ( $post_types as $post_type => $post_type_object ) {
if ( ! in_array( $post_type, $allowed, true ) ) {
continue;
}
?>
>
labels->singular_name ); ?>
manager->get_module_settings();
$selected = isset( $settings['noindex_post_types'] ) && is_array( $settings['noindex_post_types'] ) ? $settings['noindex_post_types'] : [];
$post_types = get_post_types( [ 'public' => true ], 'objects' );
$allowed = $this->manager->get_available_post_type_names();
foreach ( $post_types as $post_type => $post_type_object ) {
if ( ! in_array( $post_type, $allowed, true ) ) {
continue;
}
?>
>
labels->singular_name )
);
?>
render_format_post_type_fields( 'title_format_post_types', 'text', 'title' );
}
/**
* Render per-post-type description format overrides.
*
* @return void
*/
public function render_description_format_post_type_fields() {
$this->render_format_post_type_fields( 'description_format_post_types', 'textarea', 'description' );
}
/**
* Render per-post-type format override fields.
*
* @param string $name Settings array key holding the per-post-type overrides.
* @param string $input_type Either "text" or "textarea".
* @param string $context Either "title" or "description", used for the help text.
*
* @return void
*/
private function render_format_post_type_fields( $name, $input_type, $context ) {
$settings = $this->manager->get_module_settings();
$overrides = isset( $settings[ $name ] ) && is_array( $settings[ $name ] ) ? $settings[ $name ] : [];
$post_types = get_post_types( [ 'public' => true ], 'objects' );
$supported = $this->manager->get_supported_post_types();
foreach ( $post_types as $post_type => $post_type_object ) {
if ( ! in_array( $post_type, $supported, true ) ) {
continue;
}
$field_id = 'adminoptim-seo-' . str_replace( '_', '-', $name ) . '-' . $post_type;
$value = isset( $overrides[ $post_type ] ) ? $overrides[ $post_type ] : '';
$type_label = $post_type_object->labels->singular_name;
?>
manager->get_module_settings();
$selected = isset( $settings['default_noindex'] ) && is_array( $settings['default_noindex'] ) ? $settings['default_noindex'] : [];
foreach ( $this->get_noindex_rules() as $key => $label ) {
?>
>
manager->get_module_settings();
$name = isset( $field['name'] ) ? $field['name'] : '';
$id = 'adminoptim-seo-' . str_replace( '_', '-', $name );
$value = isset( $settings[ $name ] ) ? $settings[ $name ] : '';
$desc = isset( $field['desc'] ) ? $field['desc'] : '';
?>
manager->get_available_post_type_names();
$post_types = isset( $settings['enabled_post_types'] ) && is_array( $settings['enabled_post_types'] ) ? $settings['enabled_post_types'] : [];
$post_types = array_values( array_intersect( array_map( 'sanitize_key', $post_types ), $valid_post_types ) );
$sanitized['enabled_post_types'] = ! empty( $post_types ) ? $post_types : [ 'post' ];
$noindex_post_types = isset( $settings['noindex_post_types'] ) && is_array( $settings['noindex_post_types'] ) ? $settings['noindex_post_types'] : [];
$sanitized['noindex_post_types'] = array_values( array_intersect( array_map( 'sanitize_key', $noindex_post_types ), $post_types ) );
$valid_rules = array_keys( $this->get_noindex_rules() );
$rules = isset( $settings['default_noindex'] ) && is_array( $settings['default_noindex'] ) ? $settings['default_noindex'] : [];
$sanitized['default_noindex'] = array_values( array_intersect( array_map( 'sanitize_key', $rules ), $valid_rules ) );
$sanitized['title_format'] = isset( $settings['title_format'] ) ? $this->sanitize_format_placeholders( sanitize_text_field( $settings['title_format'] ) ) : $defaults['title_format'];
$sanitized['homepage_title_format'] = isset( $settings['homepage_title_format'] ) ? $this->sanitize_format_placeholders( sanitize_text_field( $settings['homepage_title_format'] ) ) : $defaults['homepage_title_format'];
$sanitized['title_format_post_types'] = $this->sanitize_format_post_type_overrides( $settings, 'title_format_post_types', $valid_post_types, 'sanitize_text_field' );
$sanitized['title_separator'] = isset( $settings['title_separator'] ) ? substr( sanitize_text_field( $settings['title_separator'] ), 0, 5 ) : $defaults['title_separator'];
$sanitized['description_format'] = isset( $settings['description_format'] ) ? $this->sanitize_format_placeholders( sanitize_textarea_field( $settings['description_format'] ) ) : $defaults['description_format'];
$sanitized['homepage_description_format'] = isset( $settings['homepage_description_format'] ) ? $this->sanitize_format_placeholders( sanitize_textarea_field( $settings['homepage_description_format'] ) ) : $defaults['homepage_description_format'];
$sanitized['description_format_post_types'] = $this->sanitize_format_post_type_overrides( $settings, 'description_format_post_types', $valid_post_types, 'sanitize_textarea_field' );
$sanitized['description_fallback'] = isset( $settings['description_fallback'] ) && in_array( $settings['description_fallback'], [ 'excerpt', 'content', 'site_tagline', 'empty' ], true ) ? $settings['description_fallback'] : $defaults['description_fallback'];
$sanitized['default_facebook_image'] = isset( $settings['default_facebook_image'] ) ? esc_url_raw( $settings['default_facebook_image'] ) : '';
$sanitized['default_x_image'] = isset( $settings['default_x_image'] ) ? esc_url_raw( $settings['default_x_image'] ) : '';
$sanitized['auto_canonical'] = empty( $settings['auto_canonical'] ) ? 0 : 1;
$sanitized['allow_canonical_override'] = empty( $settings['allow_canonical_override'] ) ? 0 : 1;
$sanitized['ai_include_seo_notes'] = empty( $settings['ai_include_seo_notes'] ) ? 0 : 1;
// The keys below are Pro-only: gate the assignment itself (not just the
// final return) so an inactive-Pro save leaves them unset here, letting
// carry_forward_inactive_pro_keys() re-seed them from the stored option instead
// of overwriting them with 0 just because the free form has no field for them.
if ( $this->is_pro_active() ) {
$sanitized['enable_ai_suggestions'] = empty( $settings['enable_ai_suggestions'] ) ? 0 : 1;
$sanitized['ai_include_focus_keyword'] = empty( $settings['ai_include_focus_keyword'] ) ? 0 : 1;
$sanitized['ai_include_secondary_keywords'] = empty( $settings['ai_include_secondary_keywords'] ) ? 0 : 1;
$sanitized['ai_allow_replace_existing'] = empty( $settings['ai_allow_replace_existing'] ) ? 0 : 1;
}
}
if ( $is_schema ) {
$valid_organization_types = [ 'Organization', 'LocalBusiness', 'Person' ];
$organization_type = isset( $settings['organization_type'] ) ? sanitize_text_field( $settings['organization_type'] ) : $defaults['organization_type'];
$sanitized['organization_type'] = in_array( $organization_type, $valid_organization_types, true ) ? $organization_type : $defaults['organization_type'];
$sanitized['organization_name'] = isset( $settings['organization_name'] ) ? sanitize_text_field( $settings['organization_name'] ) : '';
$sanitized['organization_alternate_name'] = isset( $settings['organization_alternate_name'] ) ? sanitize_text_field( $settings['organization_alternate_name'] ) : '';
$sanitized['organization_website_url'] = isset( $settings['organization_website_url'] ) ? esc_url_raw( $settings['organization_website_url'] ) : '';
$sanitized['organization_logo'] = isset( $settings['organization_logo'] ) ? esc_url_raw( $settings['organization_logo'] ) : '';
$sanitized['default_social_image'] = isset( $settings['default_social_image'] ) ? esc_url_raw( $settings['default_social_image'] ) : '';
$sanitized['sameas_facebook'] = isset( $settings['sameas_facebook'] ) ? esc_url_raw( $settings['sameas_facebook'] ) : '';
$sanitized['sameas_x'] = isset( $settings['sameas_x'] ) ? esc_url_raw( $settings['sameas_x'] ) : '';
$sanitized['sameas_linkedin'] = isset( $settings['sameas_linkedin'] ) ? esc_url_raw( $settings['sameas_linkedin'] ) : '';
$sanitized['sameas_instagram'] = isset( $settings['sameas_instagram'] ) ? esc_url_raw( $settings['sameas_instagram'] ) : '';
$sanitized['sameas_youtube'] = isset( $settings['sameas_youtube'] ) ? esc_url_raw( $settings['sameas_youtube'] ) : '';
$sanitized['sameas_tiktok'] = isset( $settings['sameas_tiktok'] ) ? esc_url_raw( $settings['sameas_tiktok'] ) : '';
$sanitized['sameas_pinterest'] = isset( $settings['sameas_pinterest'] ) ? esc_url_raw( $settings['sameas_pinterest'] ) : '';
$sanitized['contact_email'] = isset( $settings['contact_email'] ) ? sanitize_email( $settings['contact_email'] ) : '';
$sanitized['contact_phone'] = isset( $settings['contact_phone'] ) ? sanitize_text_field( $settings['contact_phone'] ) : '';
$sanitized['address_street'] = isset( $settings['address_street'] ) ? sanitize_text_field( $settings['address_street'] ) : '';
$sanitized['address_city'] = isset( $settings['address_city'] ) ? sanitize_text_field( $settings['address_city'] ) : '';
$sanitized['address_region'] = isset( $settings['address_region'] ) ? sanitize_text_field( $settings['address_region'] ) : '';
$sanitized['address_postal_code'] = isset( $settings['address_postal_code'] ) ? sanitize_text_field( $settings['address_postal_code'] ) : '';
$sanitized['address_country'] = isset( $settings['address_country'] ) ? sanitize_text_field( $settings['address_country'] ) : '';
$sanitized['enable_schema'] = empty( $settings['enable_schema'] ) ? 0 : 1;
$sanitized['enable_article_schema'] = empty( $settings['enable_article_schema'] ) ? 0 : 1;
}
return $this->carry_forward_inactive_pro_keys( $sanitized );
}
/**
* Get noindex rule labels.
*
* @return array
*/
private function get_noindex_rules() {
return [
'search' => __( 'Search results', 'admin-optimizer' ),
'author' => __( 'Author archives', 'admin-optimizer' ),
'date' => __( 'Date archives', 'admin-optimizer' ),
'category' => __( 'Category archives', 'admin-optimizer' ),
'tag' => __( 'Tag archives', 'admin-optimizer' ),
'404' => __( '404 pages', 'admin-optimizer' ),
];
}
/**
* Remove unsupported placeholder tokens from a user format.
*
* @param string $format Format string.
*
* @return string
*/
private function sanitize_format_placeholders( $format ) {
$supported = [
'%title%',
'%site_name%',
'%tagline%',
'%excerpt%',
'%content_summary%',
'%post_type%',
'%date%',
'%modified_date%',
'%separator%',
];
return preg_replace_callback(
'/%[a-z0-9_]+%/i',
static function ( $matches ) use ( $supported ) {
return in_array( $matches[0], $supported, true ) ? $matches[0] : '';
},
$format
);
}
/**
* Sanitize per-post-type title/description format overrides.
*
* @param array $settings Raw posted settings.
* @param string $name Settings array key holding the overrides.
* @param string[] $valid_post_types Post type names allowed as keys.
* @param callable $sanitize_value Callback used to sanitize each format string before placeholder filtering.
*
* @return array
*/
private function sanitize_format_post_type_overrides( $settings, $name, $valid_post_types, $sanitize_value ) {
$overrides = isset( $settings[ $name ] ) && is_array( $settings[ $name ] ) ? $settings[ $name ] : [];
$sanitized = [];
foreach ( $overrides as $post_type => $format ) {
$post_type = sanitize_key( $post_type );
if ( ! in_array( $post_type, $valid_post_types, true ) ) {
continue;
}
$format = $this->sanitize_format_placeholders( call_user_func( $sanitize_value, $format ) );
if ( '' !== $format ) {
$sanitized[ $post_type ] = $format;
}
}
return $sanitized;
}
/**
* Build placeholder helper text.
*
* @param array $placeholders Placeholders.
*
* @return string
*/
private function placeholder_help( $placeholders ) {
return $this->manager->get_placeholder_help( $placeholders );
}
}