'_yoast_wpseo_title', 'description' => '_yoast_wpseo_metadesc', 'canonical' => '_yoast_wpseo_canonical', 'og_title' => '_yoast_wpseo_opengraph-title', 'og_description' => '_yoast_wpseo_opengraph-description', 'og_image' => '_yoast_wpseo_opengraph-image', ); private const RANK_MATH_FIELD_MAP = array( 'title' => 'rank_math_title', 'description' => 'rank_math_description', 'canonical' => 'rank_math_canonical_url', 'og_title' => 'rank_math_facebook_title', 'og_description' => 'rank_math_facebook_description', 'og_image' => 'rank_math_facebook_image', ); private const AIOSEO_FIELD_MAP = array( 'title' => 'title', 'description' => 'description', 'canonical' => 'canonical_url', 'og_title' => 'og_title', 'og_description' => 'og_description', ); /** * Return the active supported SEO plugin slug, or '' when none is active. * * Lets the effective schema report whether seo.* writes have a real * provider behind them instead of advertising fields that would come back * `supported: false`. */ public static function active_supported_plugin(): string { $capability = new self(); $plugin = $capability->detect_seo_plugin(); return in_array( $plugin, self::SUPPORTED_PLUGINS, true ) ? $plugin : ''; } /** * Run the shared validate/execute pipeline for one SEO patch command. * * @param array $input Command input document. * @param string $phase Either `validate` or `execute`. * @return array Internal result: `['data' => array]` or `['error' => array]`. */ public function run( array $input, string $phase ): array { $post_id = $this->target_post_id( $input ); if ( $post_id <= 0 ) { return $this->error( 'invalid_target', 'wp.seo.patch requires a target post_id.', array( 'input', 'target', 'post_id' ) ); } if ( null === get_post( $post_id ) ) { return $this->error( 'not_found', 'No post matched the requested target.', array( 'input', 'target', 'post_id' ) ); } $values = $this->supported_values( $input ); if ( array() === $values ) { return $this->error( 'invalid_value', 'wp.seo.patch requires at least one supported SEO field in values.', array( 'input', 'values' ) ); } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } $plugin = $this->detect_seo_plugin(); if ( ! in_array( $plugin, self::SUPPORTED_PLUGINS, true ) ) { // Clean, non-error result: diagnosis and the manual-fix path still work. return array( 'data' => $this->seo_result( $post_id, '' === $plugin ? self::PLUGIN_NONE : $plugin, false, false, null, array( 'requested_fields' => array_keys( $values ) ) ), ); } return array( 'data' => $this->patch_supported_plugin( $plugin, $post_id, $values ) ); } /** * Dispatch a supported SEO plugin to its storage-specific adapter. * * @param string $plugin Detected supported SEO plugin slug. * @param int $post_id Target post id. * @param array $values Normalized supported field values. */ private function patch_supported_plugin( string $plugin, int $post_id, array $values ): array { switch ( $plugin ) { case self::PLUGIN_YOAST: return $this->patch_yoast( $post_id, $values ); case self::PLUGIN_RANK_MATH: return $this->patch_rank_math( $post_id, $values ); case self::PLUGIN_AIOSEO: return $this->patch_aioseo( $post_id, $values ); } return $this->seo_result( $post_id, $plugin, false, false, null, array( 'requested_fields' => array_keys( $values ) ) ); } /** * Write the supported fields through Yoast and recalculate the indexable. * * @param int $post_id Target post id. * @param array $values Normalized supported field values. */ private function patch_yoast( int $post_id, array $values ): array { $written = $this->write_mapped_meta_fields( $post_id, self::YOAST_FIELD_MAP, $values ); if ( array_key_exists( 'robots_noindex', $values ) ) { update_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', $this->truthy( $values['robots_noindex'] ) ? '1' : '2' ); $written[] = 'robots_noindex'; } if ( array_key_exists( 'robots_nofollow', $values ) ) { update_post_meta( $post_id, '_yoast_wpseo_meta-robots-nofollow', $this->truthy( $values['robots_nofollow'] ) ? '1' : '0' ); $written[] = 'robots_nofollow'; } $recalculated = $this->recalculate_yoast_indexable( $post_id ); return $this->seo_result( $post_id, self::PLUGIN_YOAST, true, array() !== $written, $written, array( 'indexable_recalculated' => $recalculated ) ); } /** * Write the supported fields through Rank Math's post meta. * * @param int $post_id Target post id. * @param array $values Normalized supported field values. */ private function patch_rank_math( int $post_id, array $values ): array { $written = $this->write_mapped_meta_fields( $post_id, self::RANK_MATH_FIELD_MAP, $values ); if ( array_key_exists( 'robots_noindex', $values ) || array_key_exists( 'robots_nofollow', $values ) ) { $robots = $this->rank_math_robots( $post_id, $values ); update_post_meta( $post_id, 'rank_math_robots', $robots ); if ( array_key_exists( 'robots_noindex', $values ) ) { $written[] = 'robots_noindex'; } if ( array_key_exists( 'robots_nofollow', $values ) ) { $written[] = 'robots_nofollow'; } } return $this->seo_result( $post_id, self::PLUGIN_RANK_MATH, true, array() !== $written, $written ); } /** * Write the supported fields through AIOSEO's custom post model/table. * * @param int $post_id Target post id. * @param array $values Normalized supported field values. */ private function patch_aioseo( int $post_id, array $values ): array { $model = $this->aioseo_post_model( $post_id ); if ( ! is_object( $model ) ) { return $this->seo_result( $post_id, self::PLUGIN_AIOSEO, true, false, array(), array( 'storage' => self::AIOSEO_STORAGE, 'warning' => 'AIOSEO post model was not available.', ) ); } $written = $this->apply_aioseo_values_to_model( $model, $values ); if ( method_exists( $model, 'save' ) ) { $model->save(); } if ( method_exists( $model, 'reset' ) ) { $model->reset(); } $this->bust_aioseo_post_cache( $post_id, $model ); return $this->seo_result( $post_id, self::PLUGIN_AIOSEO, true, array() !== $written, $written, array( 'storage' => self::AIOSEO_STORAGE ) ); } /** * Return AIOSEO's post model instance for the target post when available. * * @param int $post_id Target post id. * @return object|null */ private function aioseo_post_model( int $post_id ): ?object { $model_class = self::AIOSEO_POST_MODEL_CLASS; if ( ! class_exists( $model_class ) || ! method_exists( $model_class, 'getPost' ) ) { return null; } try { $model = $model_class::getPost( $post_id ); return is_object( $model ) ? $model : null; } catch ( \Throwable $error ) { return null; } } /** * Apply supported fields to the AIOSEO model and return public field names written. * * @param object $model AIOSEO post model. * @param array $values Normalized supported field values. * @return array */ private function apply_aioseo_values_to_model( object $model, array $values ): array { $written = array(); foreach ( self::AIOSEO_FIELD_MAP as $field => $property ) { if ( array_key_exists( $field, $values ) ) { $model->{$property} = (string) $values[ $field ]; $written[] = $field; } } if ( array_key_exists( 'robots_noindex', $values ) || array_key_exists( 'robots_nofollow', $values ) ) { $model->robots_default = false; if ( array_key_exists( 'robots_noindex', $values ) ) { $model->robots_noindex = $this->truthy( $values['robots_noindex'] ); $written[] = 'robots_noindex'; } if ( array_key_exists( 'robots_nofollow', $values ) ) { $model->robots_nofollow = $this->truthy( $values['robots_nofollow'] ); $written[] = 'robots_nofollow'; } } if ( array_key_exists( 'og_image', $values ) ) { $model->og_image_type = 'custom_image'; $model->og_image_custom_url = (string) $values['og_image']; $written[] = 'og_image'; } return $written; } /** * Clear AIOSEO's metadata cache so rendered head output sees the saved model. * * @param int $post_id Target post id. * @param object $model AIOSEO post model. */ private function bust_aioseo_post_cache( int $post_id, object $model ): void { if ( ! function_exists( 'aioseo' ) ) { return; } try { $aioseo = aioseo(); if ( ! is_object( $aioseo ) || ! isset( $aioseo->meta ) || ! is_object( $aioseo->meta ) || ! isset( $aioseo->meta->metaData ) || ! is_object( $aioseo->meta->metaData ) || ! method_exists( $aioseo->meta->metaData, 'bustPostCache' ) ) { return; } $aioseo->meta->metaData->bustPostCache( $post_id, $model ); } catch ( \Throwable $error ) { return; } } /** * Write all requested mapped string fields and return their public field names. * * @param int $post_id Target post id. * @param array $field_map Public field name to plugin meta key. * @param array $values Normalized supported field values. * @return array */ private function write_mapped_meta_fields( int $post_id, array $field_map, array $values ): array { $written = array(); foreach ( $field_map as $field => $meta_key ) { if ( array_key_exists( $field, $values ) ) { update_post_meta( $post_id, $meta_key, (string) $values[ $field ] ); $written[] = $field; } } return $written; } /** * Build the stable Assistant-facing SEO result payload. * * @param int $post_id Target post id. * @param string $plugin Detected SEO plugin slug. * @param bool $supported Whether the adapter can write this plugin. * @param bool $patched Whether any requested field was written. * @param array|null $written_fields Public field names written. * @param array $extra Additional result fields. */ private function seo_result( int $post_id, string $plugin, bool $supported, bool $patched, ?array $written_fields = null, array $extra = array() ): array { $data = array( 'resource' => 'seo', 'target' => array( 'post_id' => $post_id ), 'supported' => $supported, 'seo_plugin' => $plugin, 'patched' => $patched, ); if ( null !== $written_fields ) { $data['written_fields'] = $written_fields; } foreach ( $extra as $key => $value ) { $data[ $key ] = $value; } return $data; } /** * Merge robots directives into Rank Math's stored robots array. * * @param int $post_id Target post id. * @param array $values Normalized supported field values. * @return array */ private function rank_math_robots( int $post_id, array $values ): array { $stored = get_post_meta( $post_id, 'rank_math_robots', true ); $robots = is_array( $stored ) ? array_values( array_filter( array_map( 'strval', $stored ) ) ) : array(); if ( array_key_exists( 'robots_noindex', $values ) ) { if ( $this->truthy( $values['robots_noindex'] ) ) { $robots = $this->with_directive( $this->without_directive( $robots, 'index' ), 'noindex' ); } else { $robots = $this->with_directive( $this->without_directive( $robots, 'noindex' ), 'index' ); } } if ( array_key_exists( 'robots_nofollow', $values ) ) { if ( $this->truthy( $values['robots_nofollow'] ) ) { $robots = $this->with_directive( $this->without_directive( $robots, 'follow' ), 'nofollow' ); } else { $robots = $this->without_directive( $robots, 'nofollow' ); } } return array_values( array_unique( $robots ) ); } /** * Add a robots directive if it is not already present. * * @param array $robots Current directives. * @param string $directive Directive to add. * @return array */ private function with_directive( array $robots, string $directive ): array { if ( ! in_array( $directive, $robots, true ) ) { $robots[] = $directive; } return $robots; } /** * Remove a robots directive if present. * * @param array $robots Current directives. * @param string $directive Directive to remove. * @return array */ private function without_directive( array $robots, string $directive ): array { return array_values( array_filter( $robots, static fn ( string $value ): bool => $value !== $directive ) ); } /** * Rebuild the Yoast indexable so rendered output reflects the new meta. * * Direct post-meta writes do not update Yoast's cached indexable, so the * rendered head would stay stale. This drives Yoast's own builder to * recompute it from the freshly written meta. * * @param int $post_id Target post id. */ private function recalculate_yoast_indexable( int $post_id ): bool { if ( ! function_exists( 'YoastSEO' ) ) { return false; } try { $container = YoastSEO()->classes; if ( ! is_object( $container ) || ! method_exists( $container, 'get' ) ) { return false; } $builder = $container->get( 'Yoast\WP\SEO\Builders\Indexable_Builder' ); if ( ! is_object( $builder ) || ! method_exists( $builder, 'build_for_id_and_type' ) ) { return false; } $repository = $container->get( 'Yoast\WP\SEO\Repositories\Indexable_Repository' ); $indexable = is_object( $repository ) && method_exists( $repository, 'find_by_id_and_type' ) ? $repository->find_by_id_and_type( $post_id, 'post' ) : false; $builder->build_for_id_and_type( $post_id, 'post', $indexable ); return true; } catch ( \Throwable $error ) { return false; } } /** * Detect the active SEO plugin server-side. * * Returns the WordPress.org slug of the detected plugin, or an empty string * when none is detected. A `actionpanel_ai_active_seo_plugin` filter lets a * site override detection (e.g. when several SEO plugins coexist). */ private function detect_seo_plugin(): string { $detected = ''; if ( defined( 'WPSEO_VERSION' ) || function_exists( 'YoastSEO' ) || class_exists( 'WPSEO_Options' ) ) { $detected = self::PLUGIN_YOAST; } elseif ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) || function_exists( 'rank_math' ) ) { $detected = self::PLUGIN_RANK_MATH; } elseif ( $this->is_plugin_active_slug( self::PLUGIN_AIOSEO ) || defined( 'AIOSEO_VERSION' ) || class_exists( 'AIOSEO\Plugin\AIOSEO' ) ) { $detected = self::PLUGIN_AIOSEO; } if ( function_exists( 'apply_filters' ) ) { $detected = (string) apply_filters( 'actionpanel_ai_active_seo_plugin', $detected ); } return $detected; } /** * Return whether a plugin with the given directory slug is active. * * @param string $slug WordPress.org plugin directory slug. */ private function is_plugin_active_slug( string $slug ): bool { if ( ! function_exists( 'get_option' ) ) { return false; } $active_plugins = get_option( 'active_plugins', array() ); if ( ! is_array( $active_plugins ) ) { return false; } foreach ( $active_plugins as $plugin_file ) { if ( is_string( $plugin_file ) && str_starts_with( $plugin_file, $slug . '/' ) ) { return true; } } return false; } /** * Return the target post id from the command input. * * @param array $input Command input. */ private function target_post_id( array $input ): int { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); foreach ( array( 'post_id', 'id' ) as $key ) { if ( isset( $target[ $key ] ) && is_numeric( $target[ $key ] ) ) { return (int) $target[ $key ]; } } return 0; } /** * Return only the supported SEO field values from the command input. * * @param array $input Command input. * @return array */ private function supported_values( array $input ): array { $values = isset( $input['values'] ) && is_array( $input['values'] ) ? $input['values'] : array(); $supported = array(); foreach ( self::SUPPORTED_FIELDS as $field ) { if ( array_key_exists( $field, $values ) ) { $supported[ $field ] = $values[ $field ]; } } return $supported; } /** * Coerce a robots flag value to a boolean. * * @param mixed $value Raw value. */ private function truthy( $value ): bool { if ( is_bool( $value ) ) { return $value; } if ( is_numeric( $value ) ) { return (int) $value > 0; } if ( is_string( $value ) ) { $normalized = strtolower( trim( $value ) ); return in_array( $normalized, array( '1', 'true', 'yes', 'noindex', 'nofollow' ), true ); } return false; } /** * Build an internal error result. */ private function error( string $code, string $message, array $path = array() ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( array() !== $path ) { $error['path'] = $path; } return array( 'error' => $error ); } }