'object',
'properties' => array(
'title' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => esc_html__( 'Title to generate slug suggestions for.', 'ai' ),
),
'content' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => esc_html__( 'Content to generate slug suggestions for.', 'ai' ),
),
'context' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => esc_html__( 'Additional context or post ID.', 'ai' ),
),
'number_of_suggestions' => array(
'type' => 'integer',
'minimum' => 1,
'maximum' => 10,
'sanitize_callback' => 'absint',
'default' => 3,
'description' => esc_html__( 'Number of slug suggestions to return.', 'ai' ),
),
),
);
}
/**
* {@inheritDoc}
*
* @since 1.3.0
*/
protected function output_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'slugs' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
'description' => esc_html__( 'Generated slug suggestions.', 'ai' ),
),
),
);
}
/**
* {@inheritDoc}
*
* @since 1.3.0
*/
protected function execute_callback( $input ) {
$args = wp_parse_args(
$input,
array(
'title' => null,
'content' => null,
'context' => null,
'number_of_suggestions' => 3,
)
);
$post = null;
if ( is_numeric( $args['context'] ) ) {
$post_id = (int) $args['context'];
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'post_not_found',
/* translators: %d: Post ID. */
sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id )
);
}
// Fetch the post context when a numeric post ID is provided.
$context = get_post_context( $post->ID );
$post_content = $context['content'] ?? '';
$post_title = $post->post_title;
unset( $context['content'], $context['title'] );
// Override with explicitly passed title or content if available.
if ( $args['title'] ) {
$post_title = sanitize_text_field( $args['title'] );
}
if ( $args['content'] ) {
$post_content = normalize_content( $args['content'] );
}
} else {
$post_content = normalize_content( $args['content'] ?? '' );
$post_title = sanitize_text_field( $args['title'] ?? '' );
$context = $args['context'] ?? '';
}
if ( empty( $post_title ) && empty( $post_content ) ) {
return new WP_Error(
'insufficient_data',
esc_html__( 'Post title or content is required to generate slug suggestions.', 'ai' )
);
}
// Build the prompt input with structured XML tags for title, content, and context.
$prompt_input = '';
if ( ! empty( $post_title ) ) {
$prompt_input .= "
{$post_title}\n\n";
}
if ( ! empty( $post_content ) ) {
$prompt_input .= "{$post_content}";
}
if ( ! empty( $context ) ) {
if ( is_array( $context ) ) {
$context_lines = array();
foreach ( $context as $key => $value ) {
if ( is_array( $value ) ) {
$value = implode( ', ', $value );
}
if ( is_string( $key ) && ! is_numeric( $key ) ) {
$context_lines[] = "{$key}: {$value}";
} else {
$context_lines[] = (string) $value;
}
}
$context = implode( "\n", $context_lines );
}
$prompt_input .= "\n\n{$context}";
}
$number_of_suggestions = min( max( (int) $args['number_of_suggestions'], 1 ), 10 );
// Generate the slug suggestions from the AI model.
$suggestions = $this->generate_slugs( $prompt_input, $context, $number_of_suggestions );
if ( is_wp_error( $suggestions ) ) {
return $suggestions;
}
if ( empty( $suggestions ) ) {
return new WP_Error(
'no_results',
esc_html__( 'No slug suggestion was generated.', 'ai' )
);
}
// Sanitize the suggestions into valid, unique WordPress slugs.
$clean_slugs = array();
foreach ( $suggestions as $suggestion ) {
$clean_slug = sanitize_title( str_replace( '_', '-', $suggestion ) );
if ( empty( $clean_slug ) ) {
continue;
}
// Drop repeats from the model before spending a uniqueness lookup on them.
if ( in_array( $clean_slug, $clean_slugs, true ) ) {
continue;
}
$clean_slugs[] = $clean_slug;
}
$slugs = array();
foreach ( $clean_slugs as $clean_slug ) {
$slug = $post instanceof WP_Post ? $this->make_slug_unique( $clean_slug, $post ) : $clean_slug;
if ( empty( $slug ) ) {
continue;
}
$slugs[] = $slug;
}
$slugs = array_slice( array_values( array_unique( $slugs ) ), 0, $number_of_suggestions );
if ( empty( $slugs ) ) {
return new WP_Error(
'no_results',
esc_html__( 'No slug suggestion was generated.', 'ai' )
);
}
return array(
'slugs' => $slugs,
);
}
/**
* {@inheritDoc}
*
* @since 1.3.0
*/
protected function permission_callback( $args ) {
$post_id = isset( $args['context'] ) && is_numeric( $args['context'] ) ? absint( $args['context'] ) : null;
if ( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'post_not_found',
/* translators: %d: Post ID. */
sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id )
);
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error(
'insufficient_capabilities',
esc_html__( 'You do not have permission to generate slugs for this post.', 'ai' )
);
}
$post_type = get_post_type( $post_id );
if ( ! $post_type ) {
return false;
}
$post_type_obj = get_post_type_object( $post_type );
if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) {
return false;
}
} elseif ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'insufficient_capabilities',
esc_html__( 'You do not have permission to generate slugs.', 'ai' )
);
}
return true;
}
/**
* {@inheritDoc}
*
* @since 1.3.0
*/
protected function meta(): array {
return array(
'show_in_rest' => true,
);
}
/**
* Makes a slug unique against existing content for the given post.
*
* @since 1.3.0
*
* @param string $slug The sanitized slug to make unique.
* @param \WP_Post $post The post the slug is being generated for.
* @return string The unique slug.
*/
private function make_slug_unique( string $slug, WP_Post $post ): string {
$post_status = in_array( $post->post_status, array( 'draft', 'pending', 'auto-draft' ), true )
? 'publish'
: $post->post_status;
return wp_unique_post_slug(
$slug,
$post->ID,
$post_status,
$post->post_type,
(int) $post->post_parent
);
}
/**
* Generates slug suggestions from the prompt.
*
* @since 1.3.0
*
* @param string $prompt The prompt.
* @param mixed $context The context.
* @param int $number_of_suggestions The number of suggestions.
* @return list|\WP_Error The generated suggestions, or WP_Error.
*/
protected function generate_slugs( string $prompt, $context, int $number_of_suggestions ) {
$prompt = $this->filter_prompt( $prompt, $context );
$prompt_builder = $this->get_prompt_builder( $prompt, $number_of_suggestions );
if ( is_wp_error( $prompt_builder ) ) {
return $prompt_builder;
}
$response = $prompt_builder->generate_text();
if ( is_wp_error( $response ) ) {
return $response;
}
return $this->parse_slugs( (string) $response );
}
/**
* Decodes the structured slug response returned by the model.
*
* @since 1.3.0
*
* @param string $response The raw JSON response.
* @return list|\WP_Error The suggested slugs, or WP_Error if the response could not be parsed.
*/
private function parse_slugs( string $response ) {
$decoded = json_decode( $response, true );
if ( ! is_array( $decoded ) || ! isset( $decoded['slugs'] ) || ! is_array( $decoded['slugs'] ) ) {
return new WP_Error(
'invalid_response',
esc_html__( 'Could not parse the AI response as slug suggestions.', 'ai' )
);
}
return array_values( array_filter( $decoded['slugs'], 'is_string' ) );
}
/**
* Returns the JSON schema the model must respond with.
*
* @since 1.3.0
*
* @return array The response schema.
*/
protected function slugs_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'slugs' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
),
'required' => array( 'slugs' ),
'additionalProperties' => false,
);
}
/**
* Gets a prompt builder for generating slugs.
*
* @since 1.3.0
*
* @param string $prompt The prompt.
* @param int $number_of_suggestions The number of suggestions.
* @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or WP_Error.
*/
private function get_prompt_builder( string $prompt, int $number_of_suggestions ) {
$prompt_builder = wp_ai_client_prompt( $prompt )
->using_system_instruction( $this->get_system_instruction( null, array( 'number_of_suggestions' => $number_of_suggestions ) ) )
->as_json_response( $this->slugs_schema() );
$prompt_builder = $this->filter_prompt_builder( $prompt_builder, Slug_Generation_Experiment::class, array(), $prompt );
return $this->ensure_text_generation_supported(
$prompt_builder,
esc_html__( 'Slug generation failed. Please ensure you have a connected provider that supports text generation.', 'ai' )
);
}
}