Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 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
2ebd0e2
feat: add loading state to slug regeneration
milindmore22 Aug 11, 2026
56987cb
Merge branch 'WordPress:develop' into feat/suggest-permalink
milindmore22 Aug 11, 2026
38171cc
Merge branch 'develop' into feat/suggest-permalink
dkotter Aug 11, 2026
6ad6229
Add docs
dkotter Aug 11, 2026
725fd28
Ensure we properly check slugs against existing ones. Dedupe before c…
dkotter Aug 11, 2026
978b070
Modify the system instructions a bit to match our other ones
dkotter Aug 11, 2026
ed9da48
Refactor slug generation and settings to their own files so we don't …
dkotter Aug 11, 2026
5048cd1
Update tests
dkotter Aug 11, 2026
ce3b48f
Use structured outputs instead of asking the LLM to return content in…
dkotter Aug 11, 2026
4ced887
Make the button full-width
dkotter Aug 11, 2026
0bf9823
Various style changes. Ensure focus isn't lost on generation. Minor t…
dkotter Aug 11, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ While the Classic Editor plugin remains widely used, the AI plugin is focused on
* **[Image Generation and Editing](docs/features/image-generation.md)** - Create and edit images from post content in the editor, also via the Media Library.
* **[Meta Description Generation](docs/experiments/meta-description.md)** - Generates meta description suggestions and integrates those with various SEO plugins.
* **[Multi-Provider Support](docs/experiments/multi-provider-support.md)** - Works with AI Connector plugins for providers such as OpenAI, Google, and Anthropic.
* **[Slug Generation](docs/experiments/slug-generation.md)** - Suggests SEO-friendly permalink slugs from post title or content, in the permalink popover and the pre-publish panel.
* **Suggest Reply** - Adds a "Suggest Reply" action to the Comments screen and Activity widget, enabling moderators to quickly generate comment reply suggestions.
* **[Title Generation](docs/experiments/title-generation.md)** - Generates title suggestions from content.
* **[Type Ahead](docs/experiments/type-ahead.md)** – Contextual type-ahead assistance for suggestions while typing.
Expand Down
357 changes: 357 additions & 0 deletions docs/experiments/slug-generation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,357 @@
# Slug Generation

## Summary

The Slug Generation experiment adds AI-assisted permalink suggestions to the WordPress post editor. It surfaces a **Generate Slug** button inside the permalink popover and a **Suggest Slugs** card in the pre-publish panel, both backed by the `ai/slug-generation` Ability, which can also be called directly over REST.

## Overview

### For End Users

When enabled, the experiment adds two entry points for permalink suggestions:

1. **Permalink popover** — open the URL / permalink field in the post settings sidebar and click **Generate Slug** (**Regenerate Slug** once a slug is set). A modal opens with suggestions that can be previewed, edited, regenerated, and inserted.
2. **Pre-publish panel** — clicking **Publish** reveals a **Suggest Slugs** card where suggestions can be generated and applied without leaving the publish flow.

**Key Features:**

- Suggestions derived from the post title, content, and post context (terms, post type)
- Multiple suggestions per request (3 by default, configurable from 1 to 10)
- Every suggestion is sanitized with `sanitize_title()` so it is always a valid WordPress slug
- Suggestions are checked against existing content, so a suggestion that is already taken comes back with a numeric suffix
- Hand-editable before insertion; edits are normalized with `cleanForSlug()`
- Gated behind a minimum content length (250 characters by default) so the model has something to work with
- Language-aware — slugs use the language of the supplied title/content

### For Developers

The experiment consists of two main components:

1. **Experiment Class** (`WordPress\AI\Experiments\Slug_Generation\Slug_Generation`): registration, asset enqueuing, and localized configuration.
2. **Ability Class** (`WordPress\AI\Abilities\Slug_Generation\Slug_Generation`): prompt construction, model invocation, and slug sanitization via the WordPress Abilities API.

The ability can be called directly via REST API for automation, bulk back-fills, or custom UI integrations.

## Architecture & Implementation

### Key Hooks & Entry Points

`register()` adds two hooks:

- `wp_abilities_api_init` → `register_abilities()`, which registers `ai/slug-generation`.
- `admin_enqueue_scripts` → `enqueue_assets()`, which loads the editor bundle.

Assets are only enqueued on `post.php` and `post-new.php`, and only when the current screen's post type supports `title` and is not `attachment`. Because `Loader` skips `register()` for disabled features, none of this runs unless the experiment is enabled.

### Assets & Data Flow

The JS entry point is `src/experiments/slug-generation/index.tsx`, built to `experiments/slug-generation`. It registers a `PluginPrePublishPanel` fill and attaches the **Generate Slug** button to the permalink panel.

Because the permalink panel renders inside a `Dropdown` popover that is created and destroyed on demand, the button is mounted into its own React root inside `.editor-post-url`. A `MutationObserver` watches for that panel appearing and disappearing. The observer is attached at the document level on purpose: when the editor does not render a `Popover.Slot`, `Popover` portals into a container appended directly to `document.body`, so a narrower observation root would miss the panel. Only element nodes that are — or contain — the permalink panel are considered relevant, and matches are debounced.

The button dispatches an `ai-trigger-slug-generation` window event carrying the post ID, title, and content; the plugin component listens for it, opens the modal, and calls the ability through `runAbility()`. Both entry points share `hooks/useSlugGeneration.ts`.

Localized configuration is exposed as `window.aiSlugGenerationData`:

| Key | Source | Default |
| --- | --- | --- |
| `enabled` | `$this->is_enabled()` | — |
| `minContentLength` | `get_min_content_length( 'slug-generation', 250 )` | `250` |
| `numberOfSuggestions` | `wpai_slug_generation_number_of_suggestions` filter, clamped to 1–10 | `3` |

### Input Schema

```php
array(
'type' => 'object',
'properties' => array(
'title' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => 'Title to generate slug suggestions for.',
),
'content' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => 'Content to generate slug suggestions for.',
),
'context' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => 'Additional context. Either a string of additional context, or a post ID (as a string) — when a numeric ID is supplied, the post title, content, and terms are fetched and used.',
),
'number_of_suggestions' => array(
'type' => 'integer',
'minimum' => 1,
'maximum' => 10,
'sanitize_callback' => 'absint',
'default' => 3,
'description' => 'Number of slug suggestions to return.',
),
),
)
```

Explicit `title` and `content` values take precedence over the values fetched from a numeric `context`, so the editor can send unsaved changes.

### Output Schema

```php
array(
'type' => 'object',
'properties' => array(
'slugs' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
'description' => 'Generated slug suggestions.',
),
),
)
```

### Model Response Schema

The prompt is configured with `as_json_response()`, so the model returns structured JSON rather than free-form text. Nothing is parsed out of prose — the response is decoded and validated against this shape:

```php
array(
'type' => 'object',
'properties' => array(
'slugs' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
),
'required' => array( 'slugs' ),
'additionalProperties' => false,
)
```

A response that is not valid JSON, or that is missing a `slugs` array, returns an `invalid_response` error rather than being salvaged. Non-string entries within `slugs` are discarded.

Providers that support native structured outputs enforce this schema server-side; for providers that do not, the system instruction still describes the required shape and the decode step is the backstop.

### Permissions

- **If `context` is a numeric post ID:**
- Verifies the post exists; returns `post_not_found` otherwise.
- Checks `current_user_can( 'edit_post', $post_id )`.
- Verifies the post type has `show_in_rest` enabled — otherwise the callback returns `false`.

- **If `context` is not a post ID:**
- Checks `current_user_can( 'edit_posts' )`.

### Slug Uniqueness

Each suggestion is sanitized with `sanitize_title()` (underscores are converted to hyphens first), then made unique against existing content with `wp_unique_post_slug()` when a post is in context.

`wp_unique_post_slug()` returns the slug untouched for `draft`, `pending`, and `auto-draft` posts — which is the state a post is normally in while it is being edited. The ability therefore maps those statuses to `publish` before the uniqueness check, so a suggestion reflects the slug the post would actually receive once published. When no post is in context there is no post type or parent to resolve uniqueness against, so the sanitized suggestion is returned as-is.

Duplicate suggestions are collapsed both before the uniqueness lookup (so repeats from the model don't cost a database query) and after it (so two suggestions that resolve to the same unique slug don't both surface).

Note that this is a preview: WordPress enforces slug uniqueness again at save time, and a slug that was free during generation may have been taken by then.

## Using the Ability via REST API

### Endpoint

```text
POST /wp-json/wp-abilities/v1/abilities/ai/slug-generation/run
```

### Authentication

You can authenticate using either:

1. **Application Password** (Recommended)
2. **Cookie Authentication with Nonce**

See [TESTING_REST_API.md](../TESTING_REST_API.md) for detailed authentication instructions.

### Request Examples

#### Example 1: Generate slugs from a post ID

```bash
curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/slug-generation/run" \
-u "username:application-password" \
-H "Content-Type: application/json" \
-d '{
"input": {
"context": "123"
}
}'
```

**Response:**

```json
{
"slugs": [
"renewable-energy-modern-grid",
"grid-scale-renewables-explained",
"how-renewables-reshape-the-grid"
]
}
```

#### Example 2: Generate slugs from a title and content

```bash
curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/slug-generation/run" \
-u "username:application-password" \
-H "Content-Type: application/json" \
-d '{
"input": {
"title": "Async Decision Making for Distributed Teams",
"content": "This article walks through how distributed teams can adopt asynchronous decision-making practices to reduce meeting load while preserving alignment.",
"number_of_suggestions": 5
}
}'
```

#### Example 3: Using the JS helper inside the editor

```ts
import { runAbility } from '../../utils/run-ability';

const { slugs } = await runAbility< { slugs: string[] } >(
'ai/slug-generation',
{
title: editedTitle,
content: editedContent,
context: String( postId ),
number_of_suggestions: 3,
}
);
```

### Error Responses

- `post_not_found` — `context` was a numeric post ID but no such post exists.
- `insufficient_data` — Neither a title nor content was available to generate from.
- `insufficient_capabilities` — Caller lacks `edit_post` (with a post ID) or `edit_posts` (without).
- `invalid_response` — The model's response was not valid JSON, or did not contain a `slugs` array.
- `no_results` — The model returned an empty list, or nothing that survived sanitization.
- A `WP_Error` from `ensure_text_generation_supported()` if no connected provider supports text generation.

Example:

```json
{
"code": "insufficient_data",
"message": "Post title or content is required to generate slug suggestions.",
"data": { "status": 400 }
}
```

## Extending

### Changing the Number of Suggestions

The editor UI reads its default from a filter, clamped to 1–10:

```php
add_filter( 'wpai_slug_generation_number_of_suggestions', function (): int {
return 5;
} );
```

REST callers can pass `number_of_suggestions` per request instead.

### Adjusting the Minimum Content Length

Both entry points are disabled until the post content reaches `minContentLength` characters (excluding spaces):

```php
add_filter( 'wpai_min_content_length', function ( int $length, string $feature_id ): int {
return 'slug-generation' === $feature_id ? 100 : $length;
}, 10, 2 );
```

### Customizing the System Instruction

Edit `includes/Abilities/Slug_Generation/system-instruction.php`, or filter it per site:

```php
add_filter( 'wpai_system_instruction', function ( string $instruction, string $name ): string {
if ( 'ai/slug-generation' !== $name ) {
return $instruction;
}
return $instruction . "\nPrefer slugs of no more than four words.";
}, 10, 2 );
```

### Filtering the Prompt Builder

The ability calls `filter_prompt_builder()` with the experiment class, exposing:

```php
add_filter( 'wpai_slug_generation_prompt_builder', function ( $prompt_builder ) {
return $prompt_builder->using_temperature( 0.2 );
} );
```

### Filtering Preferred Models

The ability resolves a model through the shared `wpai_preferred_text_models` filter:

```php
add_filter( 'wpai_preferred_text_models', function ( array $models ): array {
return array(
array( 'anthropic', 'claude-sonnet-4-6' ),
array( 'openai', 'gpt-5.4-mini' ),
);
} );
```

## Testing

### Manual Testing

1. **Enable the experiment:**
- Go to `Settings → AI`
- Toggle **Slug Generation** to enabled
- Ensure an AI Connector with text generation support is configured

2. **Test the permalink popover:**
- Create a post with a title and at least 250 characters of content, then save a draft
- Open the URL / permalink field in the post settings sidebar
- Click **Generate Slug**; verify the popover closes and the modal opens with suggestions
- Edit a suggestion in **Selected slug**, click **Insert**, and verify the permalink updates
- Reopen the popover and verify the button now reads **Regenerate Slug**

3. **Test the pre-publish panel:**
- Click **Publish** and expand **Suggest Slugs**
- Generate, select, and **Apply** a suggestion; verify the permalink updates
- Verify **Apply** becomes **Applied** and is disabled once the selected slug matches the current one

4. **Test the content-length gate:**
- With a post under 250 characters, verify the button is visible but disabled, with a tooltip explaining the minimum
- Verify the pre-publish panel shows the same explanation instead of the generate controls

5. **Test slug uniqueness:**
- Publish a post with a known slug, then draft a second post whose content would produce the same slug
- Verify the suggestion comes back suffixed (e.g. `my-slug-2`) rather than colliding

6. **Test without a connector:**
- Disable all connectors and click **Generate Slug**
- Verify an error notice appears with a link to manage connectors, and the modal does not stay open

7. **Test REST API:**
- Call the endpoint with a post ID, with freeform title/content, and with an invalid post ID
- Verify `post_not_found` and `insufficient_data` error handling

## Notes & Considerations

### Requirements

- Requires an AI Connector that supports text generation.
- Only runs on `post.php` and `post-new.php`, for post types that support `title` and are not attachments.
- Users need `edit_post` (with a post ID) or `edit_posts` (without).

### Limitations

- The **Generate Slug** button is attached by DOM injection and assumes the standard `.editor-post-url` markup; heavily customized editors may need additional selectors.
- The modal is given an explicit `z-index` so it renders above the permalink popover, which relies on the popover's current stacking values.
- Suggestions are generated in real time and are not cached.
Loading
Loading