options ) ) { $this->options = self::get_default_settings(); update_option( self::OPTION_NAME, $this->options ); } $this->settings = new SEO_Manager_Settings( $this ); $this->internal_link_suggestions = new SEO_Internal_Link_Suggestions( $this ); add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] ); add_action( 'save_post', [ $this, 'save_post_meta' ], 10, 2 ); add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] ); add_action( 'wp_ajax_adminoptim_seo_internal_links', [ $this, 'ajax_internal_link_suggestions' ] ); add_filter( 'document_title_parts', [ $this, 'filter_document_title_parts' ] ); add_filter( 'ao_ai_function_hints', [ $this, 'add_ai_function_hints' ] ); add_filter( 'adminoptim_seo_metadata_provider', [ $this, 'provide_seo_metadata_provider' ] ); add_action( 'wp_head', [ $this, 'output_meta_tags' ], 1 ); } /** * Default module settings. * * @return array */ public static function get_default_settings() { return [ 'enabled_post_types' => [ 'post', 'page' ], 'noindex_post_types' => [], 'default_noindex' => [], 'title_format' => '%title% %separator% %site_name%', 'homepage_title_format' => '%site_name% %separator% %tagline%', 'title_format_post_types' => [], 'title_separator' => '|', 'description_format' => '%excerpt%', 'homepage_description_format' => '%tagline%', 'description_format_post_types' => [], 'description_fallback' => 'excerpt', 'default_facebook_image' => '', 'default_x_image' => '', 'auto_canonical' => 1, 'allow_canonical_override' => 1, 'enable_schema' => 1, 'organization_type' => 'Organization', 'organization_name' => '', 'organization_alternate_name' => '', 'organization_website_url' => '', 'organization_logo' => '', 'default_social_image' => '', 'sameas_facebook' => '', 'sameas_x' => '', 'sameas_linkedin' => '', 'sameas_instagram' => '', 'sameas_youtube' => '', 'sameas_tiktok' => '', 'sameas_pinterest' => '', 'contact_email' => '', 'contact_phone' => '', 'address_street' => '', 'address_city' => '', 'address_region' => '', 'address_postal_code' => '', 'address_country' => '', 'enable_article_schema' => 1, 'enable_ai_suggestions' => 1, 'ai_include_focus_keyword' => 1, 'ai_include_secondary_keywords' => 0, 'ai_include_seo_notes' => 1, 'ai_allow_replace_existing' => 0, ]; } /** * Get normalized module settings. * * @return array */ public function get_module_settings() { $settings = get_option( self::OPTION_NAME, [] ); if ( ! is_array( $settings ) ) { $settings = []; } return wp_parse_args( $settings, self::get_default_settings() ); } /** * Register AI Connector routing hints used by SEO Manager. * * @param array $hints Existing function hints. * * @return array */ public function add_ai_function_hints( $hints ) { $hints = is_array( $hints ) ? $hints : []; $hints['seo_internal_links'] = [ 'label' => __( 'SEO Internal Link Suggestions', 'admin-optimizer' ), 'module' => __( 'SEO Manager', 'admin-optimizer' ), 'description' => __( 'Ranks a bounded WordPress-supplied candidate list for contextual internal links.', 'admin-optimizer' ), 'provider' => 'anthropic', 'model' => 'claude-haiku-4-5', 'active' => ! empty( $this->get_module_settings()['enable_ai_suggestions'] ), ]; return $hints; } /** * Get supported post types. * * @return array */ public function get_supported_post_types() { $settings = $this->get_module_settings(); $post_types = isset( $settings['enabled_post_types'] ) && is_array( $settings['enabled_post_types'] ) ? $settings['enabled_post_types'] : []; $allowed = $this->get_available_post_type_names(); $post_types = array_values( array_intersect( $post_types, $allowed ) ); if ( empty( $post_types ) ) { $post_types = [ 'post' ]; } $post_types = array_values( array_unique( array_filter( $post_types ) ) ); $post_types = apply_filters( 'adminoptim_seo_post_types', $post_types ); if ( ! is_array( $post_types ) ) { $post_types = [ $post_types ]; } return array_values( array_unique( array_filter( $post_types ) ) ); } /** * Get public post types suitable for SEO fields. * * @return array */ public function get_available_post_type_names() { $post_types = get_post_types( [ 'public' => true, ], 'objects' ); $available = []; foreach ( $post_types as $post_type => $post_type_object ) { if ( post_type_supports( $post_type, 'title' ) && post_type_supports( $post_type, 'editor' ) ) { $available[] = $post_type; } } return array_values( array_unique( $available ) ); } /** * Check whether a post type supports SEO settings. * * @param string $post_type Post type. * * @return bool */ private function is_supported_post_type( $post_type ) { return in_array( $post_type, $this->get_supported_post_types(), true ); } /** * Add SEO metaboxes. * * @return void */ public function add_meta_boxes() { foreach ( $this->get_supported_post_types() as $post_type ) { add_meta_box( 'adminoptim_seo_manager', __( 'SEO Settings', 'admin-optimizer' ), [ $this, 'render_meta_box' ], $post_type, 'normal', 'high' ); } } /** * Render SEO metabox. * * @param \WP_Post $post Post object. * * @return void */ public function render_meta_box( $post ) { $seo_data = $this->get_post_seo_data( $post->ID ); $settings = $this->get_module_settings(); $description_range = $this->get_ai_meta_description_range( $post->ID ); $description_limit = $description_range['max'] + 1; $resolved_title_placeholder = $this->get_resolved_title( $post->ID, $settings ); $visible_title_placeholder = 'auto-draft' === $post->post_status ? str_ireplace( 'Auto Draft', '%title%', $resolved_title_placeholder ) : $resolved_title_placeholder; $visible_title_placeholder = '' !== trim( $visible_title_placeholder ) ? $visible_title_placeholder : '%title%'; $can_manage_advanced_fields = $this->current_user_can_manage_advanced_seo_fields( $post->ID ); $post_type_hidden = isset( $settings['noindex_post_types'] ) && is_array( $settings['noindex_post_types'] ) && in_array( $post->post_type, $settings['noindex_post_types'], true ); wp_nonce_field( 'adminoptim_seo_manager_' . $post->ID, 'adminoptim_seo_manager_nonce' ); ?>
base || empty( $screen->post_type ) || ! $this->is_supported_post_type( $screen->post_type ) ) { return; } $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only; used only to determine which post's SEO assets to enqueue. $analysis_post = $post_id ? get_post( $post_id ) : null; $description_range = $this->get_ai_meta_description_range( $post_id ); wp_enqueue_media(); wp_enqueue_style( 'adminoptimizer-seo-manager', self::MODULE_URL . 'assets/css/seo-manager-metabox.min.css', [], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptimizer-seo-manager', self::MODULE_URL . 'assets/js/seo-manager.min.js', [ 'jquery' ], ADMINOPTIMIZER_VERSION, true ); if ( $screen->is_block_editor() ) { $asset = require self::MODULE_PATH . 'assets/js/seo-manager-panel.asset.php'; wp_enqueue_script( 'ao-seo-manager-panel', self::MODULE_URL . 'assets/js/seo-manager-panel.min.js', $asset['dependencies'], ADMINOPTIMIZER_VERSION, true ); } wp_localize_script( 'adminoptimizer-seo-manager', 'adminoptimSeoManager', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'internalLinksAction' => 'adminoptim_seo_internal_links', 'internalLinksNonce' => wp_create_nonce( 'adminoptim_seo_internal_links' ), 'internalLinksLoading' => __( 'Finding links...', 'admin-optimizer' ), 'internalLinksDefault' => __( 'Find Suitable Internal Links', 'admin-optimizer' ), 'internalLinksEmpty' => __( 'No strong internal link opportunities were found for this article.', 'admin-optimizer' ), 'errorMessage' => __( 'Unable to find internal link suggestions. Please try again.', 'admin-optimizer' ), 'descriptionRange' => [ 'min' => $description_range['min'], 'max' => $description_range['max'], 'target' => $description_range['target'], 'limit' => $description_range['max'] + 1, ], 'checks' => [ 'found' => __( 'Focus keyword found', 'admin-optimizer' ), 'missing' => __( 'Focus keyword missing', 'admin-optimizer' ), 'metaTitle' => __( 'Meta Title', 'admin-optimizer' ), 'metaDescription' => __( 'Meta Description', 'admin-optimizer' ), 'url' => __( 'URL', 'admin-optimizer' ), 'contentOccurrences' => __( 'Keyword occurrences:', 'admin-optimizer' ), 'h1' => __( 'H1', 'admin-optimizer' ), ], 'analysisContext' => [ 'permalink' => $analysis_post instanceof \WP_Post ? get_permalink( $post_id ) : '', 'postTitle' => $analysis_post instanceof \WP_Post ? get_the_title( $post_id ) : '', 'content' => $analysis_post instanceof \WP_Post ? $this->get_trimmed_text_context( $analysis_post->post_content, 20000 ) : '', ], ] ); do_action( 'adminoptim_seo_manager_enqueue_ai_scripts', $screen->post_type ); } /** * Return review-only internal link recommendations. * * @return void */ public function ajax_internal_link_suggestions() { if ( ! check_ajax_referer( 'adminoptim_seo_internal_links', 'nonce', false ) ) { wp_send_json_error( [ 'message' => __( 'Security check failed. Please refresh and try again.', 'admin-optimizer' ) ], 403 ); } $post_id = isset( $_POST['post_id'] ) ? absint( wp_unslash( $_POST['post_id'] ) ) : 0; $post = $post_id ? get_post( $post_id ) : null; if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) { wp_send_json_error( [ 'message' => __( 'You are not allowed to edit this post.', 'admin-optimizer' ) ], 403 ); } if ( ! $post instanceof \WP_Post || ! $this->is_supported_post_type( $post->post_type ) ) { wp_send_json_error( [ 'message' => __( 'Post not found or unsupported.', 'admin-optimizer' ) ], 404 ); } if ( empty( $this->get_module_settings()['enable_ai_suggestions'] ) ) { wp_send_json_error( [ 'message' => __( 'AI SEO suggestions are disabled.', 'admin-optimizer' ) ], 403 ); } $focus_keyword = isset( $_POST['focus_keyword'] ) ? $this->limit_text_length( sanitize_text_field( wp_unslash( $_POST['focus_keyword'] ) ), 100 ) : ''; $result = $this->internal_link_suggestions->recommend( $post_id, $focus_keyword ); if ( is_wp_error( $result ) ) { $status = false !== strpos( $result->get_error_code(), 'unavailable' ) ? 503 : 400; wp_send_json_error( [ 'message' => $result->get_error_message(), 'code' => $result->get_error_code(), ], $status ); } wp_send_json_success( $result ); } /** * Get SEO data for a post. * * @param int $post_id Post ID. * * @return array */ public function get_post_seo_data( $post_id ) { $defaults = [ 'focus_keyword' => '', 'secondary_keywords' => [], 'meta_title' => '', 'meta_description' => '', 'facebook_image' => '', 'x_image' => '', 'canonical_url' => '', 'noindex' => 0, 'schema_type' => 'auto', ]; $seo_data = get_post_meta( $post_id, self::META_KEY, true ); if ( ! is_array( $seo_data ) ) { $seo_data = []; } $seo_data = wp_parse_args( $seo_data, $defaults ); $seo_data = $this->sanitize_seo_data( $seo_data, $post_id ); return apply_filters( 'adminoptim_seo_data', $seo_data, $post_id ); } /** * Apply post type-level SEO defaults for frontend output. * * @param array $seo_data SEO data. * @param int $post_id Post ID. * * @return array */ private function apply_post_type_visibility( $seo_data, $post_id ) { $settings = $this->get_module_settings(); $post_type = get_post_type( $post_id ); if ( $post_type && isset( $settings['noindex_post_types'] ) && is_array( $settings['noindex_post_types'] ) && in_array( $post_type, $settings['noindex_post_types'], true ) ) { $seo_data['noindex'] = 1; } return $seo_data; } /** * Save SEO data for a post. * * @param int $post_id Post ID. * @param array $data SEO data. * * @return void */ public function save_post_seo_data( $post_id, $data ) { update_post_meta( $post_id, self::META_KEY, $this->sanitize_seo_data( $data, $post_id ) ); } /** * Check whether the current user can manage advanced SEO fields. * * @param int $post_id Post ID. * * @return bool */ private function current_user_can_manage_advanced_seo_fields( $post_id = 0 ) { $can_manage = current_user_can( 'edit_others_posts' ); return (bool) apply_filters( 'adminoptim_seo_user_can_manage_advanced_fields', $can_manage, $post_id ); } /** * Advanced SEO fields with publishing-wide search visibility effects. * * @return array */ private function get_advanced_seo_field_keys() { $keys = apply_filters( 'adminoptim_seo_advanced_meta_fields', [ 'canonical_url', 'noindex', 'schema_type', ] ); return is_array( $keys ) ? array_values( array_filter( $keys, 'is_string' ) ) : []; } /** * Save SEO metabox data. * * @param int $post_id Post ID. * @param \WP_Post $post Post object. * * @return void */ public function save_post_meta( $post_id, $post ) { if ( ! $post instanceof \WP_Post || ! $this->is_supported_post_type( $post->post_type ) ) { return; } if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) { return; } if ( empty( $_POST['adminoptim_seo_manager_nonce'] ) ) { return; } $nonce = sanitize_text_field( wp_unslash( $_POST['adminoptim_seo_manager_nonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'adminoptim_seo_manager_' . $post_id ) ) { return; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return; } $data = []; if ( isset( $_POST['adminoptim_seo_manager'] ) && is_array( $_POST['adminoptim_seo_manager'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; sanitized via sanitize_seo_data() inside save_post_seo_data() before persistence. $data = wp_unslash( $_POST['adminoptim_seo_manager'] ); } if ( ! $this->current_user_can_manage_advanced_seo_fields( $post_id ) ) { $existing_data = $this->get_post_seo_data( $post_id ); foreach ( $this->get_advanced_seo_field_keys() as $field_key ) { if ( array_key_exists( $field_key, $existing_data ) ) { $data[ $field_key ] = $existing_data[ $field_key ]; } else { unset( $data[ $field_key ] ); } } } $this->save_post_seo_data( $post_id, $data ); } /** * Filter document title parts. * * @param array $title_parts Title parts. * * @return array */ public function filter_document_title_parts( $title_parts ) { if ( is_front_page() ) { $post_id = get_queried_object_id(); $seo_data = $post_id ? $this->get_post_seo_data( $post_id ) : []; $title_parts['title'] = $this->get_homepage_meta_title( $post_id, $seo_data, $this->get_module_settings() ); unset( $title_parts['site'], $title_parts['tagline'] ); return $title_parts; } if ( ! is_singular( $this->get_supported_post_types() ) ) { return $title_parts; } $post_id = get_queried_object_id(); $seo_data = $this->get_post_seo_data( $post_id ); $title_parts['title'] = $this->get_schema_meta_title( $post_id, $seo_data, $this->get_module_settings() ); unset( $title_parts['site'], $title_parts['tagline'] ); return $title_parts; } /** * Output frontend SEO metadata. * * @return void */ public function output_meta_tags() { $archive_noindex = $this->get_global_noindex_for_current_request(); if ( $archive_noindex ) { echo "\n"; return; } $post_id = get_queried_object_id(); $seo_data = $this->get_post_seo_data( $post_id ); $settings = $this->get_module_settings(); if ( ! is_singular( $this->get_supported_post_types() ) ) { if ( is_front_page() ) { echo $this->get_homepage_meta_output( $post_id, $seo_data, $settings ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped return; } if ( empty( $seo_data['noindex'] ) ) { echo $this->get_schema_output( $post_id, $seo_data, $settings, $this->get_canonical_url( $post_id, $seo_data, $settings ), $this->get_schema_meta_title( $post_id, $seo_data, $settings ), $this->get_schema_meta_description( $post_id, $seo_data, $settings ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } return; } $seo_data = $this->apply_post_type_visibility( $seo_data, $post_id ); remove_action( 'wp_head', 'rel_canonical' ); $permalink = get_permalink( $post_id ); $canonical_url = $this->get_canonical_url( $post_id, $seo_data, $settings ); $meta_title = $this->get_schema_meta_title( $post_id, $seo_data, $settings ); $description = $this->get_schema_meta_description( $post_id, $seo_data, $settings ); $facebook_image = $this->get_social_image( $post_id, $seo_data['facebook_image'], $settings['default_facebook_image'] ); $twitter_image = $this->get_social_image( $post_id, $seo_data['x_image'], $settings['default_x_image'] ); do_action( 'adminoptim_seo_before_output', $seo_data, $post_id ); $output = ''; if ( $this->should_output_title_tag() && ! empty( $meta_title ) ) { $output .= sprintf( "' . esc_html( $placeholder ) . '';
},
$placeholders
);
return sprintf(
/* translators: %s is a comma-separated list of placeholders. */
__( 'Available placeholders: %s', 'admin-optimizer' ),
implode( ', ', $placeholders )
);
}
/**
* Get description fallback text.
*
* @param int $post_id Post ID.
* @param string $fallback Fallback key.
*
* @return string
*/
private function get_description_fallback( $post_id, $fallback ) {
$post = get_post( $post_id );
if ( ! $post instanceof \WP_Post ) {
return '';
}
switch ( $fallback ) {
case 'content':
return $this->get_trimmed_text_context( $post->post_content, 160 );
case 'site_tagline':
return get_bloginfo( 'description' );
case 'empty':
return '';
case 'excerpt':
default:
return $this->get_excerpt_text( $post );
}
}
/**
* Get clean excerpt text.
*
* @param \WP_Post $post Post object.
*
* @return string
*/
private function get_excerpt_text( $post ) {
$excerpt = has_excerpt( $post ) ? $post->post_excerpt : wp_trim_words( $this->get_trimmed_text_context( $post->post_content, 600 ), 30, '' );
return $this->get_trimmed_text_context( $excerpt, 160 );
}
/**
* Get canonical URL for singular content.
*
* @param int $post_id Post ID.
* @param array $seo_data Post SEO data.
* @param array $settings Module settings.
*
* @return string
*/
private function get_canonical_url( $post_id, $seo_data, $settings ) {
if ( empty( $settings['allow_canonical_override'] ) ) {
return ! empty( $settings['auto_canonical'] ) ? get_permalink( $post_id ) : '';
}
$canonical_url = $this->sanitize_canonical_url( isset( $seo_data['canonical_url'] ) ? $seo_data['canonical_url'] : '', $post_id );
if ( ! empty( $settings['allow_canonical_override'] ) && ! empty( $canonical_url ) ) {
return $canonical_url;
}
return ! empty( $settings['auto_canonical'] ) ? get_permalink( $post_id ) : '';
}
/**
* Determine whether a URL points to this post's permalink, including old slugs.
*
* @param string $url URL to test.
* @param int $post_id Post ID.
*
* @return bool
*/
private function is_own_permalink_url( $url, $post_id ) {
$normalized_url = $this->normalize_url_for_comparison( $url );
$normalized_permalink = $this->normalize_url_for_comparison( get_permalink( $post_id ) );
if ( '' === $normalized_url ) {
return false;
}
if ( $normalized_permalink === $normalized_url ) {
return true;
}
if ( url_to_postid( $url ) === (int) $post_id ) {
return true;
}
$path = (string) wp_parse_url( $normalized_url, PHP_URL_PATH );
$slug = basename( untrailingslashit( $path ) );
$old_slugs = get_post_meta( $post_id, '_wp_old_slug', false );
return '' !== $slug && in_array( $slug, $old_slugs, true );
}
/**
* Normalize URLs for same-URL comparison.
*
* @param string $url URL to normalize.
*
* @return string
*/
private function normalize_url_for_comparison( $url ) {
$url = esc_url_raw( trim( (string) $url ) );
if ( '' === $url ) {
return '';
}
$parts = wp_parse_url( $url );
if ( empty( $parts['host'] ) ) {
return untrailingslashit( $url );
}
$scheme = ! empty( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) . '://' : '';
$host = strtolower( $parts['host'] );
$port = ! empty( $parts['port'] ) ? ':' . (int) $parts['port'] : '';
$path = ! empty( $parts['path'] ) ? untrailingslashit( $parts['path'] ) : '';
$query = ! empty( $parts['query'] ) ? '?' . $parts['query'] : '';
return $scheme . $host . $port . $path . $query;
}
/**
* Sanitize a canonical URL override.
*
* Canonical overrides should be absolute URLs. Relative paths are easy for
* AI to guess incorrectly for child pages, so leave them empty and let the
* automatic permalink canonical handle the current post.
*
* @param mixed $url URL to sanitize.
* @param int $post_id Post ID.
* @param array $known_own_urls Additional URLs known to belong to this post.
*
* @return string
*/
private function sanitize_canonical_url( $url, $post_id = 0, $known_own_urls = [] ) {
if ( is_array( $url ) || is_object( $url ) ) {
return '';
}
if ( ! is_array( $known_own_urls ) ) {
$known_own_urls = [];
}
$url = esc_url_raw( trim( (string) $url ) );
if ( '' === $url ) {
return '';
}
$parts = wp_parse_url( $url );
if ( ! is_array( $parts ) ) {
return '';
}
$scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : '';
if ( empty( $parts['host'] ) || ! in_array( $scheme, [ 'http', 'https' ], true ) ) {
return '';
}
if ( $post_id && $this->is_own_permalink_url( $url, $post_id ) ) {
return '';
}
$normalized_url = $this->normalize_url_for_comparison( $url );
foreach ( $known_own_urls as $known_url ) {
if ( $normalized_url && $normalized_url === $this->normalize_url_for_comparison( $known_url ) ) {
return '';
}
}
return $url;
}
/**
* Get social image URL using the per-post priority chain.
*
* @param int $post_id Post ID.
* @param string $post_image Post-level image URL.
* @param string $default_image Global fallback image URL.
*
* @return string
*/
private function get_social_image( $post_id, $post_image, $default_image ) {
if ( ! empty( $post_image ) ) {
return $post_image;
}
$featured_image = get_the_post_thumbnail_url( $post_id, 'full' );
if ( $featured_image ) {
return $featured_image;
}
$content_image = $this->get_first_content_image_url( get_post_field( 'post_content', $post_id ) );
if ( $content_image ) {
return $content_image;
}
return $default_image;
}
/**
* Check whether current archive/system page should be noindexed.
*
* @return bool
*/
private function get_global_noindex_for_current_request() {
$settings = $this->get_module_settings();
$rules = isset( $settings['default_noindex'] ) && is_array( $settings['default_noindex'] ) ? $settings['default_noindex'] : [];
if ( empty( $rules ) ) {
return false;
}
return ( is_search() && in_array( 'search', $rules, true ) )
|| ( is_author() && in_array( 'author', $rules, true ) )
|| ( is_date() && in_array( 'date', $rules, true ) )
|| ( is_category() && in_array( 'category', $rules, true ) )
|| ( is_tag() && in_array( 'tag', $rules, true ) )
|| ( is_404() && in_array( '404', $rules, true ) );
}
/**
* Sanitize a schema type value.
*
* @param mixed $schema_type Schema type.
*
* @return string
*/
private function sanitize_schema_type( $schema_type ) {
if ( is_array( $schema_type ) || is_object( $schema_type ) ) {
return 'auto';
}
$schema_type = sanitize_key( $schema_type );
return array_key_exists( $schema_type, $this->get_schema_type_options() ) ? $schema_type : 'auto';
}
/**
* Sanitize SEO data.
*
* @param array $data SEO data.
* @param int $post_id Post ID.
*
* @return array
*/
private function sanitize_seo_data( $data, $post_id = 0 ) {
$focus_keyword = '';
if ( isset( $data['focus_keyword'] ) && ! is_array( $data['focus_keyword'] ) && ! is_object( $data['focus_keyword'] ) ) {
$focus_keyword = $this->limit_text_length( sanitize_text_field( $data['focus_keyword'] ), 100 );
}
$secondary_keywords = [];
if ( isset( $data['secondary_keywords'] ) && ! is_object( $data['secondary_keywords'] ) ) {
$secondary_keywords = $this->sanitize_secondary_keywords( $data['secondary_keywords'] );
}
$known_own_urls = [];
if ( isset( $data['_rendered_permalink'] ) && ! is_array( $data['_rendered_permalink'] ) && ! is_object( $data['_rendered_permalink'] ) ) {
$known_own_urls[] = $data['_rendered_permalink'];
}
$canonical_url = isset( $data['canonical_url'] ) ? $this->sanitize_canonical_url( $data['canonical_url'], $post_id, $known_own_urls ) : '';
return [
'focus_keyword' => $focus_keyword,
'secondary_keywords' => $secondary_keywords,
'meta_title' => isset( $data['meta_title'] ) ? sanitize_text_field( $data['meta_title'] ) : '',
'meta_description' => isset( $data['meta_description'] ) ? sanitize_textarea_field( $data['meta_description'] ) : '',
'facebook_image' => isset( $data['facebook_image'] ) ? esc_url_raw( $data['facebook_image'] ) : '',
'x_image' => isset( $data['x_image'] ) ? esc_url_raw( $data['x_image'] ) : '',
'canonical_url' => $canonical_url,
'noindex' => empty( $data['noindex'] ) ? 0 : 1,
'schema_type' => isset( $data['schema_type'] ) ? $this->sanitize_schema_type( $data['schema_type'] ) : 'auto',
];
}
/**
* Sanitize secondary keywords into a deduplicated, capped array of keyword
* strings. Accepts either the raw comma-separated string submitted by the
* form field, or an already-split array (e.g. re-sanitizing a stored
* value read back from post meta).
*
* @param string|array $raw Raw secondary keywords input.
*
* @return array
*/
private function sanitize_secondary_keywords( $raw ) {
$keywords = is_array( $raw ) ? $raw : explode( ',', (string) $raw );
$seen = [];
$result = [];
foreach ( $keywords as $keyword ) {
if ( is_array( $keyword ) || is_object( $keyword ) ) {
continue;
}
$keyword = $this->limit_text_length( sanitize_text_field( trim( $keyword ) ), 100 );
if ( '' === $keyword ) {
continue;
}
$dedup_key = strtolower( $keyword );
if ( isset( $seen[ $dedup_key ] ) ) {
continue;
}
$seen[ $dedup_key ] = true;
$result[] = $keyword;
if ( count( $result ) >= 5 ) {
break;
}
}
return $result;
}
/**
* Determine whether SEO Manager should print a fallback title tag.
*
* @return bool
*/
private function should_output_title_tag(): bool {
if ( current_theme_supports( 'title-tag' ) ) {
return false;
}
if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
return false;
}
return true;
}
/**
* Strip and trim content for AI context.
*
* @param string $text Text.
* @param int $max Maximum characters.
*
* @return string
*/
private function get_trimmed_text_context( $text, $max ) {
$text = strip_shortcodes( (string) $text );
$text = wp_strip_all_tags( $text, true );
$text = preg_replace( '/\s+/', ' ', $text );
$text = trim( $text );
return $this->limit_text_length( $text, $max );
}
/**
* Get the first image URL in post content.
*
* @param string $content Post content.
*
* @return string
*/
private function get_first_content_image_url( $content ) {
if ( preg_match( '/