options ) ) {
$this->options = self::get_default_settings();
update_option( self::OPTION_NAME, $this->options );
}
$this->settings = new SEO_Manager_Settings( $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_filter( 'document_title_parts', [ $this, 'filter_document_title_parts' ] );
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() );
}
/**
* 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' ),
'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 );
}
/**
* 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( "%s\n", esc_html( $meta_title ) );
}
if ( ! empty( $description ) ) {
$output .= sprintf( "\n", esc_attr( $description ) );
}
if ( ! empty( $canonical_url ) ) {
$output .= sprintf( "\n", esc_url( $canonical_url ) );
}
if ( ! empty( $seo_data['noindex'] ) ) {
$output .= "\n";
}
$output .= sprintf( "\n", esc_attr( $meta_title ) );
$output .= sprintf( "\n", esc_attr( $description ) );
$output .= sprintf( "\n", esc_url( $permalink ) );
$output .= "\n";
if ( ! empty( $facebook_image ) ) {
$output .= sprintf( "\n", esc_url( $facebook_image ) );
}
$output .= "\n";
$output .= sprintf( "\n", esc_attr( $meta_title ) );
$output .= sprintf( "\n", esc_attr( $description ) );
if ( ! empty( $twitter_image ) ) {
$output .= sprintf( "\n", esc_url( $twitter_image ) );
}
if ( empty( $seo_data['noindex'] ) ) {
$output .= $this->get_schema_output( $post_id, $seo_data, $settings, $canonical_url, $meta_title, $description );
}
echo apply_filters( 'adminoptim_seo_meta_output', $output, $seo_data, $post_id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
do_action( 'adminoptim_seo_after_output', $seo_data, $post_id );
}
/**
* Get schema type options for the post metabox.
*
* @return array
*/
private function get_schema_type_options() {
return [
'auto' => __( 'Auto', 'admin-optimizer' ),
'article' => __( 'Article', 'admin-optimizer' ),
'webpage' => __( 'WebPage', 'admin-optimizer' ),
'faq' => __( 'FAQ', 'admin-optimizer' ),
'none' => __( 'None', 'admin-optimizer' ),
];
}
/**
* Get title text used by generated schema.
*
* @param int $post_id Post ID.
* @param array $seo_data Post SEO data.
* @param array $settings Module settings.
*
* @return string
*/
private function get_schema_meta_title( $post_id, $seo_data, $settings ) {
if ( is_front_page() ) {
return $this->get_homepage_meta_title( $post_id, $seo_data, $settings );
}
return ! empty( $seo_data['meta_title'] ) ? $this->resolve_custom_seo_text( $seo_data['meta_title'], $post_id, $settings ) : $this->get_resolved_title( $post_id, $settings );
}
/**
* Get description text used by generated schema.
*
* @param int $post_id Post ID.
* @param array $seo_data Post SEO data.
* @param array $settings Module settings.
*
* @return string
*/
private function get_schema_meta_description( $post_id, $seo_data, $settings ) {
if ( is_front_page() ) {
return $this->get_homepage_meta_description( $post_id, $seo_data, $settings );
}
return ! empty( $seo_data['meta_description'] ) ? $this->resolve_custom_seo_text( $seo_data['meta_description'], $post_id, $settings ) : $this->get_resolved_description( $post_id, $settings );
}
/**
* Get JSON-LD output for the current singular content.
*
* @param int $post_id Post ID.
* @param array $seo_data Post SEO data.
* @param array $settings Module settings.
* @param string $canonical_url Canonical URL.
* @param string $meta_title SEO meta title.
* @param string $description SEO meta description.
*
* @return string
*/
private function get_schema_output( $post_id, $seo_data, $settings, $canonical_url, $meta_title, $description ) {
if ( ! empty( $seo_data['noindex'] ) ) {
return '';
}
if ( ! $this->is_schema_request_allowed( $post_id ) ) {
return '';
}
$enabled = ! empty( $settings['enable_schema'] );
$enabled = (bool) apply_filters( 'adminoptim_seo_manager_schema_enabled', $enabled, $post_id, $seo_data, $settings );
if ( ! $enabled ) {
return '';
}
$schema_type = isset( $seo_data['schema_type'] ) ? $seo_data['schema_type'] : 'auto';
$schema_type = $this->sanitize_schema_type( apply_filters( 'adminoptim_seo_manager_schema_type', $schema_type, $post_id, $seo_data, $settings ) );
if ( 'none' === $schema_type ) {
return '';
}
$graph = $this->build_schema_graph( $post_id, $seo_data, $settings, $canonical_url, $meta_title, $description, $schema_type );
$graph = apply_filters( 'adminoptim_seo_manager_schema_graph', $graph, $post_id, $seo_data, $schema_type, $settings );
if ( empty( $graph ) || ! is_array( $graph ) ) {
return '';
}
$schema_data = [
'@context' => 'https://schema.org',
'@graph' => array_values( array_filter( $graph ) ),
];
if ( empty( $schema_data['@graph'] ) ) {
return '';
}
$encoded = wp_json_encode( $schema_data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE );
if ( ! $encoded ) {
return '';
}
return sprintf( "\n", $encoded );
}
/**
* Check whether schema can be output for the current request.
*
* @param int $post_id Post ID.
*
* @return bool
*/
private function is_schema_request_allowed( $post_id ) {
if ( is_admin() || is_feed() || is_preview() || is_search() || is_404() || is_archive() ) {
return false;
}
if (
( defined( 'REST_REQUEST' ) && REST_REQUEST )
|| ( function_exists( 'wp_is_json_request' ) && wp_is_json_request() )
|| ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() )
) {
return false;
}
$post = get_post( $post_id );
if ( ! $post instanceof \WP_Post || ! is_singular( $post->post_type ) ) {
return false;
}
$post_type_object = get_post_type_object( $post->post_type );
return $post_type_object && ! empty( $post_type_object->public );
}
/**
* Build generated schema graph for a post.
*
* @param int $post_id Post ID.
* @param array $seo_data Post SEO data.
* @param array $settings Module settings.
* @param string $canonical_url Canonical URL.
* @param string $meta_title SEO meta title.
* @param string $description SEO meta description.
* @param string $schema_type Selected schema type.
*
* @return array
*/
private function build_schema_graph( $post_id, $seo_data, $settings, $canonical_url, $meta_title, $description, $schema_type ) {
$post = get_post( $post_id );
if ( ! $post instanceof \WP_Post ) {
return [];
}
$url = ! empty( $canonical_url ) ? $canonical_url : get_permalink( $post_id );
$organization = $this->build_organization_schema( $settings );
$graph = [ $organization ];
$resolved = $this->resolve_schema_type( $schema_type, $post );
if ( 'faq' === $resolved ) {
$faq_schema = $this->build_faq_schema( $post_id, $url, $meta_title, $description );
$graph[] = ! empty( $faq_schema ) ? $faq_schema : $this->build_webpage_schema( $post_id, $url, $meta_title, $description );
} elseif ( 'article' === $resolved ) {
if ( ! empty( $settings['enable_article_schema'] ) || 'article' === $schema_type ) {
$graph[] = $this->build_article_schema( $post_id, $url, $meta_title, $description, $organization );
} else {
$graph[] = $this->build_webpage_schema( $post_id, $url, $meta_title, $description );
}
} else {
$graph[] = $this->build_webpage_schema( $post_id, $url, $meta_title, $description );
}
return $this->filter_empty_schema_values( $graph );
}
/**
* Build Organization schema.
*
* @param array $settings Module settings.
*
* @return array
*/
private function build_organization_schema( $settings ) {
$website_url = ! empty( $settings['organization_website_url'] ) ? $settings['organization_website_url'] : home_url( '/' );
$name = ! empty( $settings['organization_name'] ) ? $settings['organization_name'] : get_bloginfo( 'name' );
$type = isset( $settings['organization_type'] ) ? $settings['organization_type'] : 'Organization';
$schema = [
'@type' => in_array( $type, [ 'Organization', 'LocalBusiness', 'Person' ], true ) ? $type : 'Organization',
'@id' => trailingslashit( $website_url ) . '#organization',
'name' => $name,
'url' => $website_url,
];
if ( ! empty( $settings['organization_alternate_name'] ) ) {
$schema['alternateName'] = $settings['organization_alternate_name'];
}
if ( ! empty( $settings['organization_logo'] ) ) {
$schema['logo'] = [
'@type' => 'ImageObject',
'url' => $settings['organization_logo'],
];
}
$same_as = $this->get_schema_same_as_urls( $settings );
if ( ! empty( $same_as ) ) {
$schema['sameAs'] = $same_as;
}
if ( ! empty( $settings['contact_email'] ) || ! empty( $settings['contact_phone'] ) ) {
$schema['contactPoint'] = [
'@type' => 'ContactPoint',
'email' => isset( $settings['contact_email'] ) ? $settings['contact_email'] : '',
'telephone' => isset( $settings['contact_phone'] ) ? $settings['contact_phone'] : '',
];
}
$address = $this->get_schema_address( $settings );
if ( ! empty( $address ) ) {
$schema['address'] = $address;
}
return $this->filter_empty_schema_values( $schema );
}
/**
* Get configured sameAs profile URLs.
*
* @param array $settings Module settings.
*
* @return array
*/
private function get_schema_same_as_urls( $settings ) {
$keys = [
'sameas_facebook',
'sameas_x',
'sameas_linkedin',
'sameas_instagram',
'sameas_youtube',
'sameas_tiktok',
'sameas_pinterest',
];
$urls = [];
foreach ( $keys as $key ) {
if ( ! empty( $settings[ $key ] ) ) {
$urls[] = $settings[ $key ];
}
}
return array_values( array_unique( $urls ) );
}
/**
* Get configured PostalAddress schema.
*
* @param array $settings Module settings.
*
* @return array
*/
private function get_schema_address( $settings ) {
$address = [
'@type' => 'PostalAddress',
'streetAddress' => isset( $settings['address_street'] ) ? $settings['address_street'] : '',
'addressLocality' => isset( $settings['address_city'] ) ? $settings['address_city'] : '',
'addressRegion' => isset( $settings['address_region'] ) ? $settings['address_region'] : '',
'postalCode' => isset( $settings['address_postal_code'] ) ? $settings['address_postal_code'] : '',
'addressCountry' => isset( $settings['address_country'] ) ? $settings['address_country'] : '',
];
return $this->filter_empty_schema_values( $address );
}
/**
* Build WebPage schema.
*
* @param int $post_id Post ID.
* @param string $url URL.
* @param string $meta_title SEO meta title.
* @param string $description SEO meta description.
*
* @return array
*/
private function build_webpage_schema( $post_id, $url, $meta_title, $description ) {
$schema = [
'@type' => 'WebPage',
'@id' => trailingslashit( $url ) . '#webpage',
'url' => $url,
'name' => ! empty( $meta_title ) ? $meta_title : get_the_title( $post_id ),
'description' => $description,
'isPartOf' => [
'@id' => trailingslashit( home_url( '/' ) ) . '#website',
],
'dateModified' => get_post_modified_time( DATE_W3C, false, $post_id ),
];
$image = $this->get_schema_image_url( $post_id );
if ( ! empty( $image ) ) {
$schema['image'] = [
'@type' => 'ImageObject',
'url' => $image,
];
}
return apply_filters( 'adminoptim_seo_manager_schema_webpage', $schema, $post_id, $url );
}
/**
* Build Article/BlogPosting schema.
*
* @param int $post_id Post ID.
* @param string $url URL.
* @param string $meta_title SEO meta title.
* @param string $description SEO meta description.
* @param array $organization Organization schema.
*
* @return array
*/
private function build_article_schema( $post_id, $url, $meta_title, $description, $organization ) {
$post = get_post( $post_id );
$author_id = $post instanceof \WP_Post ? (int) $post->post_author : 0;
$author_name = $author_id ? get_the_author_meta( 'display_name', $author_id ) : '';
$image = $this->get_schema_image_url( $post_id );
$schema = [
'@type' => 'BlogPosting',
'@id' => trailingslashit( $url ) . '#article',
'mainEntityOfPage' => [
'@type' => 'WebPage',
'@id' => $url,
],
'headline' => ! empty( $meta_title ) ? $meta_title : get_the_title( $post_id ),
'description' => $description,
'author' => [
'@type' => 'Person',
'name' => $author_name,
],
'publisher' => [
'@id' => isset( $organization['@id'] ) ? $organization['@id'] : trailingslashit( home_url( '/' ) ) . '#organization',
],
'datePublished' => get_post_time( DATE_W3C, false, $post_id ),
'dateModified' => get_post_modified_time( DATE_W3C, false, $post_id ),
];
if ( ! empty( $image ) ) {
$schema['image'] = [
'@type' => 'ImageObject',
'url' => $image,
];
}
return apply_filters( 'adminoptim_seo_manager_schema_article', $schema, $post_id, $url );
}
/**
* Get schema image URL with global fallbacks.
*
* @param int $post_id Post ID.
*
* @return string
*/
private function get_schema_image_url( $post_id ) {
$image = get_the_post_thumbnail_url( $post_id, 'full' );
if ( ! empty( $image ) ) {
return $image;
}
$settings = $this->get_module_settings();
if ( ! empty( $settings['default_social_image'] ) ) {
return $settings['default_social_image'];
}
return ! empty( $settings['organization_logo'] ) ? $settings['organization_logo'] : '';
}
/**
* Build FAQPage schema when reliable FAQ entities exist.
*
* @param int $post_id Post ID.
* @param string $url URL.
* @param string $meta_title SEO meta title.
* @param string $description SEO meta description.
*
* @return array
*/
private function build_faq_schema( $post_id, $url, $meta_title, $description ) {
$entities = apply_filters( 'adminoptim_seo_manager_faq_entities', [], $post_id );
if ( ! is_array( $entities ) ) {
return [];
}
$main_entity = [];
foreach ( $entities as $entity ) {
if ( ! is_array( $entity ) || empty( $entity['question'] ) || empty( $entity['answer'] ) ) {
continue;
}
$question = sanitize_text_field( $entity['question'] );
$answer = wp_kses_post( $entity['answer'] );
if ( '' === trim( $question ) || '' === trim( wp_strip_all_tags( $answer ) ) ) {
continue;
}
$main_entity[] = [
'@type' => 'Question',
'name' => $question,
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $answer,
],
];
}
if ( empty( $main_entity ) ) {
return [];
}
$schema = [
'@type' => 'FAQPage',
'@id' => trailingslashit( $url ) . '#faq',
'url' => $url,
'name' => ! empty( $meta_title ) ? $meta_title : get_the_title( $post_id ),
'description' => $description,
'mainEntity' => $main_entity,
];
$image = $this->get_schema_image_url( $post_id );
if ( ! empty( $image ) ) {
$schema['image'] = [
'@type' => 'ImageObject',
'url' => $image,
];
}
return $schema;
}
/**
* Resolve the selected schema type.
*
* @param string $schema_type Selected schema type.
* @param \WP_Post $post Post object.
*
* @return string
*/
private function resolve_schema_type( $schema_type, $post ) {
if ( 'auto' === $schema_type ) {
return 'post' === $post->post_type ? 'article' : 'webpage';
}
return in_array( $schema_type, [ 'article', 'webpage', 'faq' ], true ) ? $schema_type : 'webpage';
}
/**
* Recursively remove empty schema values.
*
* @param mixed $value Schema value.
*
* @return mixed
*/
private function filter_empty_schema_values( $value ) {
if ( is_array( $value ) ) {
$filtered = [];
foreach ( $value as $key => $item ) {
$item = $this->filter_empty_schema_values( $item );
if ( '' !== $item && [] !== $item && null !== $item ) {
$filtered[ $key ] = $item;
}
}
return $filtered;
}
return $value;
}
/**
* Resolve a configured SEO title.
*
* @param int $post_id Post ID.
* @param array|null $settings Module settings.
*
* @return string
*/
public function get_resolved_title( $post_id, $settings = null ) {
$settings = is_array( $settings ) ? $settings : $this->get_module_settings();
$format = $this->is_static_homepage_post( $post_id ) && ! empty( $settings['homepage_title_format'] )
? $settings['homepage_title_format']
: $this->get_post_type_format( $post_id, $settings, 'title_format', 'title_format_post_types' );
$title = $this->resolve_placeholders( $format, $post_id, $settings );
$title = trim( preg_replace( '/\s+/', ' ', $title ) );
return apply_filters( 'adminoptim_seo_resolved_title', $title, $post_id, $settings );
}
/**
* Resolve a configured SEO description.
*
* @param int $post_id Post ID.
* @param array|null $settings Module settings.
*
* @return string
*/
public function get_resolved_description( $post_id, $settings = null ) {
$settings = is_array( $settings ) ? $settings : $this->get_module_settings();
$format = $this->is_static_homepage_post( $post_id ) && ! empty( $settings['homepage_description_format'] )
? $settings['homepage_description_format']
: $this->get_post_type_format( $post_id, $settings, 'description_format', 'description_format_post_types' );
$description = $this->resolve_placeholders( $format, $post_id, $settings );
$description = trim( preg_replace( '/\s+/', ' ', $description ) );
if ( '' === $description ) {
$description = $this->get_description_fallback( $post_id, $settings['description_fallback'] );
}
return apply_filters( 'adminoptim_seo_resolved_description', $description, $post_id, $settings );
}
/**
* Resolve placeholders in custom per-post SEO text.
*
* @param string $text Custom SEO text.
* @param int $post_id Post ID.
* @param array $settings Module settings.
*
* @return string
*/
private function resolve_custom_seo_text( $text, $post_id, $settings ) {
$text = $this->resolve_placeholders( $text, $post_id, $settings );
return trim( preg_replace( '/\s+/', ' ', $text ) );
}
/**
* Resolve the homepage SEO title, including latest-posts homepages.
*
* @param int $post_id Queried post ID, if the homepage is a static page.
* @param array $seo_data Static homepage SEO data.
* @param array $settings Module settings.
*
* @return string
*/
private function get_homepage_meta_title( $post_id, $seo_data, $settings ) {
if ( $post_id && ! empty( $seo_data['meta_title'] ) ) {
return $this->resolve_custom_seo_text( $seo_data['meta_title'], $post_id, $settings );
}
$format = ! empty( $settings['homepage_title_format'] ) ? $settings['homepage_title_format'] : '%site_name% %separator% %tagline%';
if ( $post_id ) {
$title = $this->resolve_placeholders( $format, $post_id, $settings );
} else {
$title = $this->resolve_site_placeholders( $format, $settings );
}
return $this->clean_homepage_title( $title, $settings );
}
/**
* Resolve the homepage SEO description, including latest-posts homepages.
*
* @param int $post_id Queried post ID, if the homepage is a static page.
* @param array $seo_data Static homepage SEO data.
* @param array $settings Module settings.
*
* @return string
*/
private function get_homepage_meta_description( $post_id, $seo_data, $settings ) {
if ( $post_id && ! empty( $seo_data['meta_description'] ) ) {
return $this->resolve_custom_seo_text( $seo_data['meta_description'], $post_id, $settings );
}
$format = ! empty( $settings['homepage_description_format'] ) ? $settings['homepage_description_format'] : '%tagline%';
if ( $post_id ) {
$description = $this->resolve_placeholders( $format, $post_id, $settings );
} else {
$description = $this->resolve_site_placeholders( $format, $settings );
}
$description = trim( preg_replace( '/\s+/', ' ', $description ) );
if ( '' === $description && $post_id ) {
$description = $this->get_description_fallback( $post_id, $settings['description_fallback'] );
}
return $description;
}
/**
* Build metadata output for latest-posts homepages.
*
* @param int $post_id Queried post ID.
* @param array $seo_data SEO data.
* @param array $settings Module settings.
*
* @return string
*/
private function get_homepage_meta_output( $post_id, $seo_data, $settings ) {
$meta_title = $this->get_homepage_meta_title( $post_id, $seo_data, $settings );
$description = $this->get_homepage_meta_description( $post_id, $seo_data, $settings );
$canonical_url = home_url( '/' );
$facebook_image = $this->get_social_image( $post_id, isset( $seo_data['facebook_image'] ) ? $seo_data['facebook_image'] : '', $settings['default_facebook_image'] );
$twitter_image = $this->get_social_image( $post_id, isset( $seo_data['x_image'] ) ? $seo_data['x_image'] : '', $settings['default_x_image'] );
$output = '';
if ( $this->should_output_title_tag() && ! empty( $meta_title ) ) {
$output .= sprintf( "%s\n", esc_html( $meta_title ) );
}
if ( ! empty( $description ) ) {
$output .= sprintf( "\n", esc_attr( $description ) );
}
if ( ! empty( $settings['auto_canonical'] ) ) {
$output .= sprintf( "\n", esc_url( $canonical_url ) );
}
$output .= sprintf( "\n", esc_attr( $meta_title ) );
$output .= sprintf( "\n", esc_attr( $description ) );
$output .= sprintf( "\n", esc_url( $canonical_url ) );
$output .= "\n";
if ( ! empty( $facebook_image ) ) {
$output .= sprintf( "\n", esc_url( $facebook_image ) );
}
$output .= "\n";
$output .= sprintf( "\n", esc_attr( $meta_title ) );
$output .= sprintf( "\n", esc_attr( $description ) );
if ( ! empty( $twitter_image ) ) {
$output .= sprintf( "\n", esc_url( $twitter_image ) );
}
return apply_filters( 'adminoptim_seo_homepage_meta_output', $output, $seo_data, $post_id );
}
/**
* Clean the resolved homepage title.
*
* @param string $title Resolved title.
* @param array $settings Module settings.
*
* @return string
*/
private function clean_homepage_title( $title, $settings ) {
$title = trim( preg_replace( '/\s+/', ' ', $title ) );
$separator = isset( $settings['title_separator'] ) ? trim( (string) $settings['title_separator'] ) : '|';
if ( '' === $separator ) {
return $title;
}
$separator = preg_quote( $separator, '/' );
$title = preg_replace( '/^(?:\s*' . $separator . '\s*)+|(?:\s*' . $separator . '\s*)+$/', '', $title );
return trim( $title );
}
/**
* Resolve site-level placeholders without requiring a post object.
*
* @param string $format Format string.
* @param array $settings Module settings.
*
* @return string
*/
private function resolve_site_placeholders( $format, $settings ) {
$site_name = get_bloginfo( 'name' );
$tagline = get_bloginfo( 'description' );
$placeholders = [
'%title%' => $site_name,
'%site_name%' => $site_name,
'%tagline%' => $tagline,
'%excerpt%' => $tagline,
'%content_summary%' => $tagline,
'%post_type%' => __( 'Homepage', 'admin-optimizer' ),
'%date%' => date_i18n( get_option( 'date_format' ) ),
'%modified_date%' => date_i18n( get_option( 'date_format' ) ),
'%separator%' => isset( $settings['title_separator'] ) ? $settings['title_separator'] : '|',
];
$placeholders = apply_filters( 'adminoptim_seo_site_placeholders', $placeholders, $settings );
return strtr( (string) $format, $placeholders );
}
/**
* Check whether a post is the configured static homepage.
*
* @param int $post_id Post ID.
*
* @return bool
*/
private function is_static_homepage_post( $post_id ) {
return 'page' === get_option( 'show_on_front' ) && (int) get_option( 'page_on_front' ) === (int) $post_id;
}
/**
* Get the format string to use for a post, preferring a per-post-type override.
*
* @param int $post_id Post ID.
* @param array $settings Module settings.
* @param string $default_key Settings key holding the global default format.
* @param string $overrides_key Settings key holding the per-post-type overrides.
*
* @return string
*/
private function get_post_type_format( $post_id, $settings, $default_key, $overrides_key ) {
$post = get_post( $post_id );
if ( $post instanceof \WP_Post
&& isset( $settings[ $overrides_key ] )
&& is_array( $settings[ $overrides_key ] )
&& ! empty( $settings[ $overrides_key ][ $post->post_type ] )
) {
return $settings[ $overrides_key ][ $post->post_type ];
}
return isset( $settings[ $default_key ] ) ? $settings[ $default_key ] : '';
}
/**
* Resolve supported SEO placeholders for a post.
*
* @param string $format Format string.
* @param int $post_id Post ID.
* @param array $settings Module settings.
*
* @return string
*/
public function resolve_placeholders( $format, $post_id, $settings = [] ) {
$post = get_post( $post_id );
if ( ! $post instanceof \WP_Post ) {
return '';
}
$post_type_object = get_post_type_object( $post->post_type );
$placeholders = [
'%title%' => get_the_title( $post_id ),
'%site_name%' => get_bloginfo( 'name' ),
'%tagline%' => get_bloginfo( 'description' ),
'%excerpt%' => $this->get_excerpt_text( $post ),
'%content_summary%' => $this->get_trimmed_text_context( $post->post_content, 160 ),
'%post_type%' => $post_type_object ? $post_type_object->labels->singular_name : $post->post_type,
'%date%' => get_the_date( '', $post_id ),
'%modified_date%' => get_the_modified_date( '', $post_id ),
'%separator%' => isset( $settings['title_separator'] ) ? $settings['title_separator'] : '|',
];
$placeholders = apply_filters( 'adminoptim_seo_placeholders', $placeholders, $post_id, $settings );
return strtr( (string) $format, $placeholders );
}
/**
* Build placeholder helper text for metabox field descriptions.
*
* @param array $placeholders Placeholders.
*
* @return string
*/
public function get_placeholder_help( $placeholders ) {
$placeholders = array_map(
static function ( $placeholder ) {
return '' . 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( '/
]+src=["\']([^"\']+)["\']/i', (string) $content, $matches ) ) {
return esc_url_raw( html_entity_decode( $matches[1], ENT_QUOTES ) );
}
return '';
}
/**
* Limit text length safely.
*
* @param string $text Text.
* @param int $max Maximum characters.
*
* @return string
*/
public function limit_text_length( $text, $max ) {
if ( function_exists( 'mb_substr' ) ) {
return mb_substr( $text, 0, $max );
}
return substr( $text, 0, $max );
}
/**
* Get text length safely.
*
* @param string $text Text.
*
* @return int
*/
private function get_text_length( $text ) {
if ( function_exists( 'mb_strlen' ) ) {
return mb_strlen( $text );
}
return strlen( $text );
}
/**
* Get the normalized AI meta description character range.
*
* @param int $post_id Post ID, when available.
*
* @return array{min:int,max:int,target:int}
*/
public function get_ai_meta_description_range( $post_id = 0 ) {
$defaults = [
'min' => 150,
'max' => 159,
'target' => 157,
];
/**
* Filters the target character range for AI-generated meta descriptions.
*
* @param array $range Character range containing min, max, and target.
* @param int $post_id Post ID, when available.
*/
$range = apply_filters( 'adminoptim_seo_ai_meta_description_range', $defaults, absint( $post_id ) );
$range = is_array( $range ) ? $range : $defaults;
$min = isset( $range['min'] ) && is_numeric( $range['min'] ) ? absint( $range['min'] ) : $defaults['min'];
$max = isset( $range['max'] ) && is_numeric( $range['max'] ) ? absint( $range['max'] ) : $defaults['max'];
$target = isset( $range['target'] ) && is_numeric( $range['target'] ) ? absint( $range['target'] ) : $defaults['target'];
$min = max( 1, min( 319, $min ) );
$max = max( $min, min( 320, $max ) );
$target = max( $min, min( $max, $target ) );
return compact( 'min', 'max', 'target' );
}
}