Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f4165c7
feat: Add Internal Link Suggestions AI experiment
Infinite-Null Jul 20, 2026
244b572
refactor: migrate inline styles to CSS and clean up internal links co…
Infinite-Null Jul 21, 2026
e987b20
chore: update internal links minimum content length threshold to 75
Infinite-Null Jul 21, 2026
d52ff9f
Merge branch 'develop' into feature/internal-link-suggestions
Infinite-Null Jul 21, 2026
24fdbab
chore: reorder and update experimental experiment class list
Infinite-Null Jul 21, 2026
86e82a1
test: add integration tests for Internal_Links ability
Infinite-Null Jul 22, 2026
b480d7c
test: add end-to-end testing support for the Internal Link Suggestion…
Infinite-Null Jul 23, 2026
00733dd
test: add end-to-end testing support for the Internal Link Suggestion…
Infinite-Null Jul 23, 2026
5aa9913
feat: add internal linking e2e test 'Can use the Internal Link Sugges…
Infinite-Null Jul 24, 2026
d9618ab
fix: correct docblock namespace references and simplify permission lo…
Infinite-Null Jul 24, 2026
2732715
fix: add missing WordPressVIPMinimum sniff ignore for post__not_in qu…
Infinite-Null Jul 24, 2026
362b237
refactor: remove unnecessary integer casting for post IDs in internal…
Infinite-Null Jul 24, 2026
d55551b
test: Add integration tests for Internal_Links experiment and clean u…
Infinite-Null Jul 27, 2026
9153973
feat: Prevent redundant link suggestions by excluding already-linked …
Infinite-Null Jul 27, 2026
4e614db
fix: Reindex excluded anchors and improve variable naming in internal…
Infinite-Null Jul 27, 2026
c544662
refactor: enhance internal link suggestions with post validation, pro…
Infinite-Null Aug 3, 2026
d2e30d5
refactor: rebrand post-specific terminology to content, enforce integ…
Infinite-Null Aug 4, 2026
e14a08b
refactor: simplify internal link logic, update model selection, and a…
Infinite-Null Aug 4, 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
488 changes: 488 additions & 0 deletions includes/Abilities/Internal_Links/Internal_Links.php

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions includes/Abilities/Internal_Links/system-instruction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
/**
* System instruction for the Internal Links ability.
*
* @package WordPress\AI\Abilities\Internal_Links
*/

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

// phpcs:ignore Squiz.PHP.Heredoc.NotAllowed, PluginCheck.CodeAnalysis.Heredoc.NotAllowed
return <<<'INSTRUCTION'
You are an internal-linking assistant for a WordPress site. Your task is to read a post's plain-text content and a list of other pages/posts published on the same site, then suggest the most valuable internal links that could be added.

## Rules — read these carefully

1. **Use only existing text as anchor text.** Every `anchor_text` value you return MUST be an exact substring of the post content provided in <post-content> tags. Do NOT invent, rephrase, or summarise. Copy the phrase character-for-character.
2. **Match to the site index.** Each suggestion must reference a URL from the <site-index> list. Do NOT invent URLs.
3. **Relevance first.** Only suggest a link when the target page is genuinely relevant to the anchor phrase in context. Avoid superficial keyword matches.
4. **No duplicates.** Do not suggest the same anchor text or the same URL more than once.
5. **Respect the cap.** Return at most the number of suggestions specified in <max-suggestions>.
6. **Context sentence.** For each suggestion, copy the sentence or clause from the post that contains the anchor text into the `context` field. This helps the editor understand placement.
7. **Quality over quantity.** If fewer than <max-suggestions> high-quality links exist, return fewer. An empty array is valid if no good matches exist.
8. **Skip already-linked text.** If an `<already-linked>` list is provided, do NOT suggest any anchor text that appears in that list. Those phrases are already hyperlinked in the post.

## Output format

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.

Is this needed? We already provide the output format we expect when making a request so seems like this is unnecessary and wastes tokens


Return a JSON object with a single key `suggestions` whose value is an array. Each element has:
- `anchor_text` (string) — exact phrase from the post content.
- `url` (string) — the target URL from the site index.
- `title` (string) — the title of the target page as given in the site index.
- `context` (string) — the sentence or clause from the post that contains the anchor text.

Example:
{
"suggestions": [
{
"anchor_text": "REST API",
"url": "https://example.com/guide-to-rest-api/",
"title": "Guide to REST API",
"context": "You can query data using the REST API endpoint provided by WordPress."
}
]
}

If there are no good suggestions, return: { "suggestions": [] }
INSTRUCTION;
1 change: 1 addition & 0 deletions includes/Experiments/Experiments.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ final class Experiments {
\WordPress\AI\Experiments\Meta_Description\Meta_Description::class,
\WordPress\AI\Experiments\Title_Generation\Title_Generation::class,
\WordPress\AI\Experiments\Type_Ahead\Type_Ahead::class,
\WordPress\AI\Experiments\Internal_Links\Internal_Links::class,

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.

We'll want to sort this alphabetically

);

/**
Expand Down
117 changes: 117 additions & 0 deletions includes/Experiments/Internal_Links/Internal_Links.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php
/**
* Internal Links experiment implementation.
*
* @package WordPress\AI
*/

declare( strict_types=1 );

namespace WordPress\AI\Experiments\Internal_Links;

use WordPress\AI\Abilities\Internal_Links\Internal_Links as Internal_Links_Ability;
use WordPress\AI\Abstracts\Abstract_Feature;
use WordPress\AI\Asset_Loader;
use WordPress\AI\Experiments\Experiment_Category;

use function WordPress\AI\get_min_content_length;

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

/**
* Internal Links experiment.
*
* Uses AI to suggest contextual internal links within a post by analysing
* the current draft and identifying relevant published posts or pages on
* the same site. All suggestions require editor review before being applied.

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.

I'd probably just remove this

*
* @since x.x.x
*/
class Internal_Links extends Abstract_Feature {

/**
* {@inheritDoc}
*/
public static function get_id(): string {
return 'internal-links';
}

/**
* {@inheritDoc}
*/
protected function load_metadata(): array {
return array(
'label' => __( 'Internal Link Suggestions', 'ai' ),
'description' => __( 'Uses AI to suggest relevant internal links within post content, using existing text as anchor text. All suggestions require editor review before being applied. Requires an AI connector that includes support for text generation models.', 'ai' ),

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.

No need to call out uses AI as most everything we ship uses AI.

'category' => Experiment_Category::EDITOR,
);
}

/**
* {@inheritDoc}
*/
public function register(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) );

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.

Let's change the default priority here so we can ensure the Generate Editorial Notes and Apply Editorial Updates buttons stay next to each other if all of those are turned on. For example, in the Summarization experiment, we set the priority to 5 to help with this

}

/**
* Registers the internal links ability.
*
* @since x.x.x
*/
public function register_abilities(): void {
wp_register_ability(
'ai/' . $this->get_id(),
array(
'label' => $this->get_label(),
'description' => $this->get_description(),
'ability_class' => Internal_Links_Ability::class,
)
);
}

/**
* Enqueues and localises the block editor script.
*
* @since x.x.x
*/
public function enqueue_assets(): void {
Asset_Loader::enqueue_script( 'internal_links', 'experiments/internal-links', array( 'include_core_abilities' => true ) );
Asset_Loader::enqueue_style( 'internal_links', 'experiments/internal-links' );
Asset_Loader::localize_script(
'internal_links',
'InternalLinksData',
array(
'enabled' => $this->is_enabled(),
'minContentLength' => get_min_content_length( 'internal-links', 75 ),
'maxSuggestions' => $this->get_max_suggestions(),
)
);
}

/**
* Returns the configured maximum number of link suggestions.
*
* Defaults to 5 and can be overridden via the `wpai_internal_links_max_suggestions` filter.
*
* @since x.x.x
*
* @return int Maximum number of suggestions (clamped to 1–10).
*/
private function get_max_suggestions(): int {
/**
* Filters the maximum number of internal link suggestions returned per request.
*
* @since x.x.x
*
* @param int $max Maximum suggestions (default 5, clamped to 1–10).
*/
$max = (int) apply_filters( 'wpai_internal_links_max_suggestions', 5 );

return max( 1, min( 10, $max ) );
}
}
101 changes: 101 additions & 0 deletions src/experiments/internal-links/components/InternalLinksPlugin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* WordPress dependencies
*/
import { Button, Flex, FlexItem, Spinner } from '@wordpress/components';
import { PluginPostStatusInfo } from '@wordpress/editor';
import { useInstanceId } from '@wordpress/compose';
import { __, sprintf } from '@wordpress/i18n';
import { link } from '@wordpress/icons';

/**
* Internal dependencies
*/
import { useInternalLinks } from '../hooks/useInternalLinks';
import SuggestionList from './SuggestionList';

export default function InternalLinksPlugin() {
const {
isLoading,
suggestions,
isContentTooShort,
minContentLength,
fetchSuggestions,
acceptSuggestion,
dismissSuggestion,
} = useInternalLinks();

const descriptionId = useInstanceId(
InternalLinksPlugin,
'internal-links-plugin-description'
);

if ( ! ( window as any ).aiInternalLinksData?.enabled ) {
return null;
}

const buttonLabel = isLoading
? __( 'Suggesting links…', 'ai' )
: __( 'Suggest Internal Links', 'ai' );

const buttonDescription = isContentTooShort
? sprintf(
/* translators: %d: minimum number of characters required. */
__(
'Internal Link Suggestions will be available when the post content has at least %d characters.',
'ai'
),
minContentLength
)
: __(
'Analyses this post and suggests relevant internal links using existing text as anchor text.',

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.

This shows post on all post types, for instance on a page. Maybe change post to just content?

'ai'
);

return (
<PluginPostStatusInfo>
<Flex direction="column" gap={ 2 }>
<FlexItem>
<Button
accessibleWhenDisabled
variant="secondary"
icon={ isLoading ? <Spinner /> : link }
onClick={ fetchSuggestions }
isBusy={ isLoading }
disabled={ isLoading || isContentTooShort }
className="ai-internal-links__plugin-button"
__next40pxDefaultSize
aria-describedby={ descriptionId }
>
{ buttonLabel }
</Button>
</FlexItem>

<FlexItem>
<span
id={ descriptionId }
className="description ai-internal-links__plugin-description"
>
{ buttonDescription }
</span>
</FlexItem>

{ suggestions.length > 0 && (
<FlexItem>
<p className="description ai-internal-links__suggestions-header">
{ sprintf(
/* translators: %d: number of suggestions found. */
__( '%d suggestion(s) found.', 'ai' ),

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.

Should use _n here for plurals

suggestions.length
) }
</p>
<SuggestionList
suggestions={ suggestions }
onAccept={ acceptSuggestion }
onDismiss={ dismissSuggestion }
/>
</FlexItem>
) }
</Flex>
</PluginPostStatusInfo>
);
}
75 changes: 75 additions & 0 deletions src/experiments/internal-links/components/SuggestionList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* WordPress dependencies
*/
import { Button, ExternalLink } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { check, trash } from '@wordpress/icons';

/**
* Internal dependencies
*/
import type { LinkSuggestion } from '../hooks/useInternalLinks';

interface Props {
suggestions: LinkSuggestion[];
onAccept: ( suggestion: LinkSuggestion ) => void;
onDismiss: ( suggestion: LinkSuggestion ) => void;
}

export default function SuggestionList( {
suggestions,
onAccept,
onDismiss,
}: Props ) {
if ( suggestions.length === 0 ) {
return null;
}

return (
<ul className="ai-internal-links__suggestions">
{ suggestions.map( ( suggestion ) => (
<li
key={ suggestion.anchor_text }
className="ai-internal-links__suggestion"
>
<p className="ai-internal-links__suggestion-anchor">
<strong>{ `"${ suggestion.anchor_text }"` }</strong>
</p>
<p className="ai-internal-links__suggestion-target">
{ __( 'Links to:', 'ai' ) }{ ' ' }
<ExternalLink href={ suggestion.url }>
{ suggestion.title }
</ExternalLink>
</p>
{ suggestion.context && (
<p className="ai-internal-links__suggestion-context">
{ `"…${ suggestion.context }…"` }
</p>
) }
<div className="ai-internal-links__suggestion-actions">
<Button
variant="secondary"
icon={ check }
iconSize={ 16 }
size="small"
onClick={ () => onAccept( suggestion ) }
__next40pxDefaultSize={ false }
>
{ __( 'Accept', 'ai' ) }
</Button>
<Button
variant="tertiary"
icon={ trash }
iconSize={ 16 }
size="small"
onClick={ () => onDismiss( suggestion ) }
__next40pxDefaultSize={ false }
>
{ __( 'Dismiss', 'ai' ) }
</Button>
</div>
</li>
) ) }
</ul>
);
}
Loading
Loading