Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
4834027
Add slug generation ability and system instructions with tests
milindmore22 Jul 24, 2026
6560675
Register slug generation experiment and integration tests
milindmore22 Jul 24, 2026
6cfa883
Add webpack entry, types, and styles for slug generation feature
milindmore22 Jul 24, 2026
a6c6515
Add UI components and pre-publish panel for slug generation
milindmore22 Jul 24, 2026
12d4068
test: add snackbar assertions to experiment toggles, include clean st…
milindmore22 Jul 24, 2026
8aaad26
test: simplify experiment snackbar assertions and ignore graphify-out…
milindmore22 Jul 24, 2026
8fe69e7
chore: update slug generation versioning to x.x.x and ignore graphify…
milindmore22 Jul 24, 2026
af8a3be
docs: improve code documentation
milindmore22 Jul 24, 2026
45e45da
chore: add JSDoc props to SlugGenerationModal
milindmore22 Jul 24, 2026
b33ed3b
chore: ignore graphify output directory and update test access modifi…
milindmore22 Jul 24, 2026
0530904
test: add extensive unit test coverage for Slug_Generation ability
milindmore22 Jul 24, 2026
e0eed59
Merge branch 'develop' into feat/suggest-permalink
jeffpaul Aug 5, 2026
37d992a
test: add extensive integration tests for slug generation logic
milindmore22 Jul 24, 2026
5399fc0
test: update slug generation test case to handle empty post types
milindmore22 Jul 24, 2026
48cc31b
chore: remove unused guideline_categories method.
milindmore22 Aug 3, 2026
bfb738c
feat: add validation and filtering for slug suggestions
milindmore22 Aug 3, 2026
4d18633
feat: improve slug language matching
milindmore22 Aug 5, 2026
69b0005
chore: ignore graphify-out directory and correct documentation for en…
milindmore22 Aug 5, 2026
87cf9db
feat: reorder slug generation experiment in registration list
milindmore22 Aug 5, 2026
6e34d46
feat: add end-to-end test suite and mock API fixtures for slug genera…
milindmore22 Aug 5, 2026
2587bb4
refactor: remove obsolete guideline_categories test
milindmore22 Aug 5, 2026
ec91087
feat: update slug generation button styling, remove inline styles
milindmore22 Aug 6, 2026
f2ed237
feat: add current slug detection to SlugGenerationButton and update b…
milindmore22 Aug 6, 2026
676dabc
refactor: improve slug popover closing logic
milindmore22 Aug 6, 2026
bd493ca
feat: standardize slug suggestion count logic
milindmore22 Aug 6, 2026
e1450cd
feat: improve slug generation context formatting and ensure uniquenes…
milindmore22 Aug 6, 2026
5ca010f
refactor: replace slug suggestion buttons with RadioControl and enhan…
milindmore22 Aug 6, 2026
ea89f69
refactor: update slug selection logic in modal
milindmore22 Aug 6, 2026
9819268
refactor: type-safe global window access for slug generation settings…
milindmore22 Aug 6, 2026
b82d316
refactor: optimize slug generation observer with debouncing, targeted…
milindmore22 Aug 6, 2026
216c4d0
chore: reset global screen state in slug generation tests
milindmore22 Aug 6, 2026
18cf443
Fix linting issues
milindmore22 Aug 6, 2026
2ffc92d
refactor: remove redundant array filtering in slug generation
milindmore22 Aug 6, 2026
662a62d
refactor: update slug retrieval logic to fallback to generated_slug
milindmore22 Aug 6, 2026
1e950c8
chore: add spacing to slug suggestion components
milindmore22 Aug 6, 2026
beee53d
style: refactor slug pre-publish panel layout
milindmore22 Aug 6, 2026
8c38adc
style: update slug generator button layout
milindmore22 Aug 6, 2026
cefda9c
refactor: update mutation observer to track document body
milindmore22 Aug 6, 2026
1cd296e
chore: update slug generation container layout styles
milindmore22 Aug 6, 2026
cebfaa5
refactor: optimize slug collection logic
milindmore22 Aug 6, 2026
7132e0e
refactor: improve slug generation component formatting, consistency.
milindmore22 Aug 6, 2026
e4355c1
fix: improve slug panel detection and update E2E tests for WordPress …
milindmore22 Aug 6, 2026
777289a
chore: reformat slug generation test
milindmore22 Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 326 additions & 0 deletions includes/Abilities/Slug_Generation/Slug_Generation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
<?php
/**
* Slug generation WordPress Ability implementation.
*
* @package WordPress\AI
*/

declare( strict_types=1 );

namespace WordPress\AI\Abilities\Slug_Generation;

use WP_Error;
use WordPress\AI\Abstracts\Abstract_Ability;
use WordPress\AI\Experiments\Slug_Generation\Slug_Generation as Slug_Generation_Experiment;

use function WordPress\AI\get_post_context;
use function WordPress\AI\normalize_content;

/**
* Slug generation WordPress Ability.
*
* @since x.x.x
*/
class Slug_Generation extends Abstract_Ability {

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function input_schema(): array {
return array(
'type' => '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 x.x.x
*/
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 x.x.x
*/
protected function execute_callback( $input ) {
$args = wp_parse_args(
$input,
array(
'title' => null,
'content' => null,
'context' => null,
'number_of_suggestions' => 3,
)
);

$post_id = null;
$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'] );

// 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 .= "<title>{$post_title}</title>\n\n";
}
if ( ! empty( $post_content ) ) {
$prompt_input .= "<content>{$post_content}</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<additional-context>{$context}</additional-context>";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So our context here loses it's keys, so no way to know what each piece of data actually represents. Ideally we build a Key: value string

}

$number_of_suggestions = (int) $args['number_of_suggestions'];
$number_of_suggestions = min( max( $number_of_suggestions, 1 ), 10 );

// Generate the raw slug suggestion text from the AI model.
$result = $this->generate_slugs( $prompt_input, $context, $number_of_suggestions );

if ( is_wp_error( $result ) ) {
return $result;
}

if ( empty( $result ) ) {
return new WP_Error(
'no_results',
esc_html__( 'No slug suggestion was generated.', 'ai' )
);
}

// Parse the output lines into clean, sanitized, and unique WordPress slugs.
$lines = explode( "\n", $result );
$slugs = array();
foreach ( $lines as $line ) {
$line = trim( $line, " \t\n\r\0\x0B\"'" );
if ( empty( $line ) ) {
continue;
}

$clean_slug = sanitize_title( str_replace( '_', '-', $line ) );
if ( empty( $clean_slug ) ) {
continue;
}

if ( $post instanceof \WP_Post ) {
$slug = wp_unique_post_slug(
$clean_slug,
$post->ID,
$post->post_status,
$post->post_type,
$post->post_parent
);
} else {
$slug = wp_unique_post_slug( $clean_slug, 0, 'publish', 'post', 0 );
}

if ( empty( $slug ) ) {
continue;
}

$slugs[] = $slug;
}

$slugs = array_slice( 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 x.x.x
*/
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 x.x.x
*/
protected function meta(): array {
return array(
'show_in_rest' => true,
);
}

/**
* Generates slug suggestions from the prompt.
*
* @since x.x.x
*
* @param string $prompt The prompt.
* @param mixed $context The context.
* @param int $number_of_suggestions The number of suggestions.
* @return string|\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;
}

return $prompt_builder->generate_text();
}

/**
* Gets a prompt builder for generating slugs.
*
* @since x.x.x
*
* @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 ) ) )
->using_temperature( 0.5 );

$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' )
);
}
}
30 changes: 30 additions & 0 deletions includes/Abilities/Slug_Generation/system-instruction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php
/**
* System instruction for the Slug Generation ability.
*
* @package WordPress\AI\Abilities\Slug_Generation
*/

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

$wpai_slug_num = isset( $number_of_suggestions ) ? (int) $number_of_suggestions : 3;

return sprintf(
'You are an editorial assistant that generates permalink slug suggestions for online articles and pages.

Goal: You will be provided with a title and/or content, and optionally some additional context. You should generate a list of url-safe, concise, keyword-relevant permalink slug options (separated by newlines) that represent the content.

The slug suggestions should follow these requirements:
- Be concise (typically 2 to 5 words) and optimized for SEO.
- Focus on key concepts and relevant keywords.
- Use only lowercase letters, numbers, and hyphens.
- Do not include any file extensions (e.g., .html, .php).
- Ensure the slug suggestions use words that match the language of the title/content you are given. For example, if the title is in Spanish, use Spanish words in the slug.
- Output exactly %d suggestions, one per line.
- Do not include any markdown, bullets, numbering, or formatting.
- Output only the raw slug text. Respond directly without preamble. Do not wrap the output in quotes. Do not add closing remarks or follow-up questions.',
$wpai_slug_num

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we want a line in here asking it to match the language of the title given? We do this for other abilities to ensure the content returned matches the language of the content passed in

);
1 change: 1 addition & 0 deletions includes/Experiments/Experiments.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class Experiments {
\WordPress\AI\Experiments\Editorial_Updates\Editorial_Updates::class,
\WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class,
\WordPress\AI\Experiments\Meta_Description\Meta_Description::class,
\WordPress\AI\Experiments\Slug_Generation\Slug_Generation::class,
\WordPress\AI\Experiments\Title_Generation\Title_Generation::class,
\WordPress\AI\Experiments\Type_Ahead\Type_Ahead::class,
);
Expand Down
Loading
Loading