array( 'label' => __( 'News / Publisher', 'ai-o-optimiser' ), 'description' => __( 'Prioritise news posts, NewsArticle, publisher identity and author identity.', 'ai-o-optimiser' ), ), 'blog_editorial' => array( 'label' => __( 'Blog / Editorial', 'ai-o-optimiser' ), 'description' => __( 'Editorial content where Article or BlogPosting may be the appropriate primary model.', 'ai-o-optimiser' ), ), 'business_services' => array( 'label' => __( 'Business / Services', 'ai-o-optimiser' ), 'description' => __( 'A business site centred on service, solution or expertise pages.', 'ai-o-optimiser' ), ), 'ecommerce' => array( 'label' => __( 'Ecommerce', 'ai-o-optimiser' ), 'description' => __( 'A commerce site where individual Product entities are a primary schema concern.', 'ai-o-optimiser' ), ), 'local_business' => array( 'label' => __( 'Local business', 'ai-o-optimiser' ), 'description' => __( 'A location-led business where local entity identity is important.', 'ai-o-optimiser' ), ), 'general' => array( 'label' => __( 'General / Other', 'ai-o-optimiser' ), 'description' => __( 'Keep the general-purpose Schema Audit policy and configure scope manually.', 'ai-o-optimiser' ), ), ); } /** * Current confirmed profile. * * @param bool $refresh Ignore the request-local cache. * @return array */ public static function current( $refresh = false ) { if ( ! $refresh && null !== self::$profile_cache ) { return self::$profile_cache; } $value = get_option( self::OPTION, array() ); $value = is_array( $value ) ? $value : array(); $profiles = self::profiles(); $key = isset( $value['profile'] ) ? sanitize_key( $value['profile'] ) : ''; if ( ! isset( $profiles[ $key ] ) ) { self::$profile_cache = array(); return self::$profile_cache; } $value['profile'] = $key; $value['news_post_types'] = self::validate_news_post_types( isset( $value['news_post_types'] ) ? $value['news_post_types'] : array() ); self::$profile_cache = $value; return self::$profile_cache; } /** * Reset the request-local cache after a write or in focused tests. * * @return void */ public static function reset_cache() { self::$profile_cache = null; } /** * Is a site profile explicitly confirmed? * * @return bool */ public static function is_confirmed() { return ! empty( self::current()['profile'] ); } /** * Current profile key. * * @return string */ public static function profile_key() { $current = self::current(); return isset( $current['profile'] ) ? (string) $current['profile'] : ''; } /** * News post types explicitly mapped by the administrator. * * @return array */ public static function news_post_types() { $current = self::current(); if ( 'news_publisher' !== ( $current['profile'] ?? '' ) ) { return array(); } $types = isset( $current['news_post_types'] ) ? self::validate_news_post_types( $current['news_post_types'] ) : array(); return ! empty( $types ) ? $types : self::suggested_news_post_types(); } /** * Suggest a profile from local WordPress/schema signals only. * * The suggestion is never saved automatically. The administrator confirms * intent because a site can legitimately combine several business models. * * @return string */ public static function suggested_profile() { $report = get_option( 'ai_o_optimiser_schema_review_cache', array() ); if ( is_array( $report ) && ! empty( $report['rows'] ) && is_array( $report['rows'] ) ) { foreach ( $report['rows'] as $row ) { $types = ! empty( $row['schema_types'] ) && is_array( $row['schema_types'] ) ? $row['schema_types'] : array(); if ( in_array( 'NewsArticle', $types, true ) || in_array( 'NewsMediaOrganization', $types, true ) ) { return 'news_publisher'; } } } $public_types = get_post_types( array( 'public' => true ), 'names' ); $public_types = is_array( $public_types ) ? array_values( $public_types ) : array(); foreach ( $public_types as $type ) { if ( preg_match( '/(^|_)(news|newsroom|article|articles|story|stories|press)(_|$)/i', (string) $type ) ) { return 'news_publisher'; } } if ( in_array( 'product', $public_types, true ) || class_exists( 'WooCommerce' ) ) { return 'ecommerce'; } foreach ( $public_types as $type ) { if ( preg_match( '/(^|_)(service|services|solution|solutions)(_|$)/i', (string) $type ) ) { return 'business_services'; } if ( preg_match( '/(^|_)(location|locations|branch|branches)(_|$)/i', (string) $type ) ) { return 'local_business'; } } if ( function_exists( 'wp_count_posts' ) ) { $count = wp_count_posts( 'post' ); if ( is_object( $count ) && ! empty( $count->publish ) && (int) $count->publish >= 5 ) { return 'blog_editorial'; } } return 'general'; } /** * Public content types that can deliberately be mapped as news content. * * Normal Pages and attachments are excluded from the news-article mapping; * site identity pages remain separate from the news content workload. * * @return array Post type => human label. */ public static function news_post_type_options() { $objects = get_post_types( array( 'public' => true ), 'objects' ); $objects = is_array( $objects ) ? $objects : array(); $out = array(); foreach ( $objects as $type => $object ) { $type = sanitize_key( $type ); if ( in_array( $type, array( 'page', 'attachment' ), true ) ) { continue; } $label = ! empty( $object->labels->name ) ? (string) $object->labels->name : ( ! empty( $object->label ) ? (string) $object->label : $type ); $out[ $type ] = $label; } if ( isset( $out['post'] ) ) { $post_label = $out['post']; unset( $out['post'] ); asort( $out, SORT_NATURAL | SORT_FLAG_CASE ); $out = array( 'post' => $post_label ) + $out; } else { asort( $out, SORT_NATURAL | SORT_FLAG_CASE ); } return $out; } /** * Suggested news mapping for a publisher. * * Default WordPress Posts are included when public. Obvious news/article CPTs * are added automatically, but the administrator can change the mapping. * * @return array */ public static function suggested_news_post_types() { $options = self::news_post_type_options(); $out = array(); if ( isset( $options['post'] ) ) { $out[] = 'post'; } foreach ( $options as $type => $label ) { $haystack = $type . ' ' . $label; if ( preg_match( '/\b(news|newsroom|article|articles|story|stories|press)\b/i', $haystack ) ) { $out[] = $type; } } return array_values( array_unique( $out ) ); } /** * Validate mapped news post types against current public content types. * * @param mixed $types Raw post types. * @return array */ public static function validate_news_post_types( $types ) { $options = self::news_post_type_options(); $types = is_array( $types ) ? $types : array(); $types = array_values( array_unique( array_filter( array_map( 'sanitize_key', $types ) ) ) ); return array_values( array_intersect( $types, array_keys( $options ) ) ); } /** * Does a row/post type belong to the confirmed news content mapping? * * @param string $post_type Post type. * @return bool */ public static function is_news_post_type( $post_type ) { return 'news_publisher' === self::profile_key() && in_array( sanitize_key( $post_type ), self::news_post_types(), true ); } /** * Make an explicitly mapped news CPT use the article context. * * @param string $context Existing inferred context. * @param WP_Post $post Post being scanned. * @return string */ public static function filter_schema_context( $context, $post ) { $post_type = is_object( $post ) && ! empty( $post->post_type ) ? (string) $post->post_type : ''; if ( self::is_news_post_type( $post_type ) ) { return 'article'; } return $context; } /** * Tailor primary/supporting Schema expectations to the confirmed site model. * * NewsArticle is substantive for mapped news content. Person and the publisher * identity are supporting on an article page so their absence is visible but * does not inflate the primary-issue count. Relationship/property validation * remains a separate follow-up layer rather than pretending type presence alone * proves a fully connected graph. * * @param array $model Primary/supporting/advanced expectation model. * @param string $context Inferred target context. * @param array $row Audit row. * @return array */ public static function filter_expectation_model( $model, $context, $row ) { if ( 'news_publisher' !== self::profile_key() || ! is_array( $model ) ) { return $model; } $model = wp_parse_args( $model, array( 'primary' => array(), 'supporting' => array(), 'advanced' => array() ) ); if ( 'home' === $context ) { $model['primary'] = array( 'NewsMediaOrganization', 'WebSite' ); $model['supporting'] = array_values( array_unique( array_merge( (array) $model['supporting'], array( 'WebPage' ) ) ) ); return $model; } $post_type = is_array( $row ) && ! empty( $row['post_type'] ) ? sanitize_key( $row['post_type'] ) : ''; if ( 'article' === $context && self::is_news_post_type( $post_type ) ) { $model['primary'] = array( 'NewsArticle' ); $model['supporting'] = array_values( array_unique( array_merge( (array) $model['supporting'], array( 'Person', 'NewsMediaOrganization' ) ) ) ); } return $model; } /** * Keep publisher/news identity repairs native-first where a provider exists. * * @param array $types Native-preferred types. * @param array $providers Detected providers. * @param array $row Audit row. * @return array */ public static function filter_native_preferred_types( $types, $providers, $row ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- WordPress passes this argument to the hook; the signature has to accept it. $types = is_array( $types ) ? $types : array(); if ( 'news_publisher' === self::profile_key() ) { $types[] = 'NewsArticle'; $types[] = 'NewsMediaOrganization'; } return array_values( array_unique( $types ) ); } /** * If a managed Article fallback is genuinely needed for mapped news content, * render it as NewsArticle rather than generic Article. * * Native-provider repair remains preferred; this only affects the existing * conservative fallback path after the administrator explicitly applies it. * * @param array $node Article node. * @param int $post_id Post ID. * @return array */ public static function filter_article_schema_node( $node, $post_id ) { if ( is_array( $node ) && $post_id && function_exists( 'get_post_type' ) && self::is_news_post_type( get_post_type( $post_id ) ) ) { $node['@type'] = 'NewsArticle'; } return $node; } /** * Save a confirmed site profile. * * @return void */ public static function handle_save() { if ( ! current_user_can( self::capability() ) ) { wp_die( esc_html__( 'You do not have permission to change the AI-O Optimiser site profile.', 'ai-o-optimiser' ) ); } check_admin_referer( 'ai_o_optimiser_save_site_profile' ); $profiles = self::profiles(); $profile = isset( $_POST['site_profile'] ) ? sanitize_key( wp_unslash( $_POST['site_profile'] ) ) : ''; if ( ! isset( $profiles[ $profile ] ) ) { wp_die( esc_html__( 'Choose a valid site profile.', 'ai-o-optimiser' ) ); } $news_post_types = isset( $_POST['news_post_types'] ) && is_array( $_POST['news_post_types'] ) ? self::validate_news_post_types( map_deep( wp_unslash( $_POST['news_post_types'] ), 'sanitize_key' ) ) : array(); if ( 'news_publisher' === $profile && empty( $news_post_types ) ) { $news_post_types = self::suggested_news_post_types(); } $value = array( 'policy_version' => self::POLICY_VERSION, 'profile' => $profile, 'news_post_types' => $news_post_types, 'confirmed_at' => gmdate( 'c' ), ); if ( false === get_option( self::OPTION, false ) ) { add_option( self::OPTION, $value, '', 'no' ); } else { update_option( self::OPTION, $value, false ); } self::reset_cache(); $scope_applied = false; if ( 'news_publisher' === $profile && ! empty( $_POST['apply_profile_scope'] ) && ! empty( $news_post_types ) ) { $scope = get_option( self::SCHEMA_SCOPE_OPTION, array() ); $scope = is_array( $scope ) ? $scope : array(); $scope['post_types'] = $news_post_types; update_option( self::SCHEMA_SCOPE_OPTION, $scope, false ); $scope_applied = true; } $redirect = isset( $_POST['redirect_to'] ) ? wp_validate_redirect( esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ), admin_url( 'admin.php?page=ai-o-optimiser-schema-review' ) ) : admin_url( 'admin.php?page=ai-o-optimiser-schema-review' ); $redirect = add_query_arg( array( 'ai_o_optimiser_profile_saved' => 1, 'ai_o_optimiser_profile_scope' => $scope_applied ? 1 : 0, ), $redirect ); wp_safe_redirect( $redirect ); exit; } /** * Scoped onboarding/profile card on AI-O Optimiser screens only. * * @return void */ public static function render_admin_profile() { if ( ! current_user_can( self::capability() ) ) { return; } $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen state (page slug, tab, redirect result notices). Nothing is written here, so there is no form submission to carry a nonce. if ( ! in_array( $page, array( 'ai-o-optimiser-dashboard', 'ai-o-optimiser-schema-review', 'ai-o-optimiser-settings' ), true ) ) { return; } $current = self::current(); $confirmed = ! empty( $current['profile'] ); $profiles = self::profiles(); $suggested = self::suggested_profile(); $selected = $confirmed ? $current['profile'] : $suggested; $news_selected = $confirmed && 'news_publisher' === $current['profile'] ? self::news_post_types() : self::suggested_news_post_types(); if ( ! empty( $_GET['ai_o_optimiser_profile_saved'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen state (page slug, tab, redirect result notices). Nothing is written here, so there is no form submission to carry a nonce. echo '

' . esc_html__( 'Site profile saved.', 'ai-o-optimiser' ) . ' '; if ( ! empty( $_GET['ai_o_optimiser_profile_scope'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen state (page slug, tab, redirect result notices). Nothing is written here, so there is no form submission to carry a nonce. echo esc_html__( 'Schema Audit scope was updated to the selected news content types.', 'ai-o-optimiser' ) . ' '; } echo esc_html__( 'Run a fresh Schema Audit to apply the tailored expectations to rendered public output; existing cached evidence is not silently rewritten.', 'ai-o-optimiser' ) . '

'; } echo '
'; if ( $confirmed ) { $label = isset( $profiles[ $selected ]['label'] ) ? $profiles[ $selected ]['label'] : $selected; echo '

' . esc_html__( 'AI-O Optimiser site profile:', 'ai-o-optimiser' ) . ' ' . esc_html( $label ) . ''; if ( 'news_publisher' === $selected ) { echo ' ' . esc_html__( 'Mapped news content requires NewsArticle as its primary model; Person and NewsMediaOrganization are checked as supporting identities.', 'ai-o-optimiser' ); } echo '

'; echo '
' . esc_html__( 'Change site profile or news content mapping', 'ai-o-optimiser' ) . ''; } else { echo '

' . esc_html__( 'Tailor AI-O Optimiser to this site', 'ai-o-optimiser' ) . '

'; echo '

' . esc_html__( 'Confirm the site model so Schema Audit can focus on the entities and content types that matter. AI-O Optimiser only suggests a profile; it will not change Rank Math, Yoast or other provider settings automatically.', 'ai-o-optimiser' ) . '

'; if ( isset( $profiles[ $suggested ] ) ) { echo '

' . esc_html__( 'Suggested:', 'ai-o-optimiser' ) . ' ' . esc_html( $profiles[ $suggested ]['label'] ) . '

'; } } self::render_form( $page, $selected, $news_selected, ! $confirmed ); if ( $confirmed ) { echo '
'; } echo '
'; } /** * Render the compact profile form. * * @param string $page Current AI-O Optimiser page. * @param string $selected Selected profile. * @param array $news_selected Selected news post types. * @param bool $apply_scope Check the scope action by default. * @return void */ private static function render_form( $page, $selected, $news_selected, $apply_scope ) { $profiles = self::profiles(); $news_options = self::news_post_type_options(); $redirect = admin_url( 'admin.php?page=' . rawurlencode( $page ) ); ?>


>

$label ) : ?>