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

', '' ); ?>


0 / 60

get_placeholder_help( [ '%title%', '%site_name%', '%tagline%', '%excerpt%', '%post_type%', '%date%', '%modified_date%', '%separator%' ] ), [ 'code' => [] ] ); ?>

0 /

get_placeholder_help( [ '%excerpt%', '%content_summary%', '%title%', '%site_name%', '%tagline%', '%post_type%', '%date%', '%modified_date%' ] ), [ 'code' => [] ] ); ?>

>

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