` tag wrapping the
* anchor phrase), the inserter splices the modified text into the
* content at a safe position. "Safe" is enforced by {@see HtmlSafetyGuard}
* — positions inside HTML tags, attribute values, JSON blobs, or
* existing anchors are skipped.
*
* The inserter is deterministic and content-only — it does not read or
* write the database, fetch posts, or fire WordPress hooks. Callers are
* responsible for persisting the modified content (typically via
* `wp_update_post` or a direct `$wpdb->update` on `post_content`).
*
* @since 1.0.0
*/
final class LinkInserter {
/**
* @var HtmlSafetyGuard
*/
private HtmlSafetyGuard $safety;
/**
* @var LinkAttributeParser
*/
private LinkAttributeParser $parser;
/**
* @since 1.0.0
*
* @param HtmlSafetyGuard|null $safety Optional. Defaults to a fresh instance.
* @param LinkAttributeParser|null $parser Optional. Defaults to a fresh instance.
*/
public function __construct( ?HtmlSafetyGuard $safety = null, ?LinkAttributeParser $parser = null ) {
$this->safety = $safety ?? new HtmlSafetyGuard();
$this->parser = $parser ?? new LinkAttributeParser();
}
/**
* Returns whether a byte range in content is safe to insert a link.
*
* A position is safe when it is not inside an HTML tag, attribute
* value, JSON blob, or existing anchor element.
*
* Exposed as public so callers that do their own position-finding
* (e.g. `InsertLinkHandler`'s anchor-only fallback) can reuse the
* same safety logic without duplicating it.
*
* @since 1.0.0
*
* @param string $content Content string.
* @param int $start Byte offset of the candidate start.
* @param int $end Byte offset of the candidate end.
*
* @return bool True when safe to insert at this position.
*/
public function is_safe_position( string $content, int $start, int $end ): bool {
return $this->safety->is_safe( $content, $start, $end );
}
/**
* Inserts a link by finding the anchor text in raw post_content.
*
* This is the primary insertion method for the suggestion metabox and
* Gutenberg sidebar. Every candidate match is checked against
* {@see HtmlSafetyGuard} before insertion, so text inside an existing
* `` tag, an HTML attribute, or Gutenberg block-comment JSON is
* never touched.
*
* Four matching strategies are tried in order (exact → case-insensitive
* → whitespace-normalised → inline-HTML-aware). Returns on the first
* successful replacement.
*
* This method does NOT write to the database. Callers receive the
* modified content string and are responsible for persisting it.
*
* @since 1.0.0
* @since 1.5.0 Every candidate match is now checked with `HtmlSafetyGuard`
* before insertion (previously only the legacy `insert()`
* method did this).
*
* @param string $content Raw post_content (may contain block markup).
* @param string $anchor Anchor phrase to find and wrap.
* @param string $link_html The `` tag to insert.
*
* @return LinkInsertionResult Success carries modified content; failure
* carries a reason code.
*/
public function insert_anchor( string $content, string $anchor, string $link_html ): LinkInsertionResult {
if ( '' === $anchor ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_EMPTY_INPUT, $content );
}
// Idempotency guard.
if ( false !== mb_strpos( $content, $link_html ) ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_ALREADY_PRESENT, $content );
}
// The whole content is the search window: segment_start 0, full byte length.
$modified = $this->insert_into_segment( $content, 0, strlen( $content ), $anchor, $link_html );
if ( null === $modified ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_NO_SAFE_POSITION, $content );
}
return LinkInsertionResult::success( $modified );
}
/**
* Inserts a link bound to the anchor's context sentence.
*
* Locates `$sentence` in `$content` and inserts the link on the first safe
* occurrence of `$anchor` WITHIN that sentence — so the linked occurrence is
* the one the suggestion was generated from (and the one the editor UI
* highlights). Falls back to {@see insert_anchor()} (first safe occurrence in
* the whole content) when the sentence cannot be located verbatim — e.g. the
* post was edited after the suggestion was generated — or contains no safe
* occurrence, so there is never a regression versus the context-free path.
*
* The editor highlights the first occurrence within the same sentence, so the
* highlighted and linked occurrence are defined identically and cannot diverge.
*
* @since 1.3.0
* @since 1.5.0 Locates the sentence and splices the result using byte
* offsets (`stripos()`/`strlen()`) instead of character
* offsets (`mb_stripos()`/`mb_strlen()`), because
* `HtmlSafetyGuard` and `insert_into_segment()` both work in
* bytes; mixing the two offset kinds would misalign the
* search window on multibyte content.
*
* @param string $content Raw post_content.
* @param string $anchor Anchor phrase to find and wrap.
* @param string $sentence Context sentence the anchor was chosen from.
* @param string $link_html The `` tag to insert.
*
* @return LinkInsertionResult
*/
public function insert_anchor_in_context( string $content, string $anchor, string $sentence, string $link_html ): LinkInsertionResult {
if ( '' === $anchor ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_EMPTY_INPUT, $content );
}
// Idempotency guard.
if ( false !== mb_strpos( $content, $link_html ) ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_ALREADY_PRESENT, $content );
}
$span = '' !== $sentence ? self::locate_sentence( $content, $sentence ) : null;
if ( null === $span ) {
// Sentence not located at all — bind nothing, use the safe default.
return $this->insert_anchor( $content, $anchor, $link_html );
}
// The located span is the search window; insert_into_segment() still
// receives the FULL content so that HtmlSafetyGuard sees real
// surrounding context (a lone sentence, cut out on its own, can look
// like safe plain text even when it actually sits inside a
// block-comment JSON blob).
[ $position, $length ] = $span;
$modified = $this->insert_into_segment( $content, $position, $length, $anchor, $link_html );
if ( null === $modified ) {
// No safe occurrence inside the sentence — fall back.
return $this->insert_anchor( $content, $anchor, $link_html );
}
return LinkInsertionResult::success( $modified );
}
/**
* Locates a context sentence inside raw content, tolerating markup.
*
* The sentence arrives here already stripped of tags — the suggestion
* engine tokenises plain text — but the content it must be found in is
* the raw `post_content`. A plain `stripos()` therefore fails for every
* sentence the editor formatted (``, ``, ``, an inline
* link) and for any whitespace the serialiser wrapped differently, which
* silently demotes the suggestion to the whole-content fallback and can
* link an unrelated earlier occurrence of the same anchor.
*
* A verbatim match is tried first and returns immediately. Otherwise both
* sides are compared with every tag and every whitespace character
* removed, while recording the byte offset in the raw content that each
* surviving character came from — so a hit maps straight back to a real
* span in the original markup.
*
* Whitespace is dropped rather than collapsed because a tag can sit
* anywhere, including between a word and its punctuation
* (`fresh,`). Collapsing would have to decide whether that tag
* stands for a space, and either answer is wrong somewhere. Ignoring
* whitespace entirely sidesteps the question and also absorbs newlines,
* indentation, and ` ` runs from the serialiser. The needle is a
* whole sentence, so matching without word boundaries is not a
* realistic source of false positives.
*
* @since 1.12.0
* @since 1.18.3 The content side drops multi-byte whitespace too, such as
* the no-break space the block editor often stores after a
* link. The needle side already did, so any sentence
* containing one was never found.
*
* @param string $content Raw post_content.
* @param string $sentence Tag-stripped context sentence.
*
* @return array{0: int, 1: int}|null Byte offset and byte length of the
* sentence within $content, or null
* when it cannot be located.
*/
private static function locate_sentence( string $content, string $sentence ): ?array {
$position = stripos( $content, $sentence );
if ( false !== $position ) {
return array( $position, strlen( $sentence ) );
}
$needle = (string) preg_replace( '/\s+/u', '', $sentence );
if ( '' === $needle ) {
return null;
}
$flat = '';
$offsets = array();
$length = strlen( $content );
$in_tag = false;
for ( $i = 0; $i < $length; $i++ ) {
$char = $content[ $i ];
if ( $in_tag ) {
if ( '>' === $char ) {
$in_tag = false;
}
continue;
}
if ( '<' === $char ) {
$in_tag = true;
continue;
}
$whitespace = self::whitespace_length( $content, $i );
if ( $whitespace > 0 ) {
// Skip the character's remaining bytes as well; the loop's
// own increment covers the first.
$i += $whitespace - 1;
continue;
}
$flat .= $char;
$offsets[] = $i;
}
$found = stripos( $flat, $needle );
if ( false === $found ) {
return null;
}
$last = $found + strlen( $needle ) - 1;
if ( ! isset( $offsets[ $found ], $offsets[ $last ] ) ) {
return null;
}
$start = $offsets[ $found ];
// +1 because the recorded offset is the last matched character's own
// position, and the span is exclusive at its end.
$end = $offsets[ $last ] + 1;
return array( $start, $end - $start );
}
/**
* Returns the byte length of the whitespace character at an offset, or 0.
*
* Must drop exactly what `\s` drops from the needle in locate_sentence().
* PHP's `u` modifier turns on Unicode character properties, so there
* `\s` also covers the no-break space and the other Unicode spaces,
* which are two or three bytes long in UTF-8. The same `\s` is used
* here for those, so the two sides cannot disagree.
*
* @since 1.18.3
*
* @param string $content Raw post_content.
* @param int $offset Byte offset of a character's first byte.
*
* @return int Byte length of the whitespace character, or 0 when the
* character is not whitespace.
*/
private static function whitespace_length( string $content, int $offset ): int {
$byte = ord( $content[ $offset ] );
// Tab, line feed, vertical tab, form feed, carriage return, space.
if ( 0x20 === $byte || ( $byte >= 0x09 && $byte <= 0x0D ) ) {
return 1;
}
// Any other ASCII byte, and a UTF-8 continuation byte, cannot start
// a whitespace character. Only lead bytes reach the regex, so its
// offset is always a character boundary.
if ( $byte < 0xC2 ) {
return 0;
}
return 1 === preg_match( '/\G\s/u', $content, $match, 0, $offset ) ? strlen( $match[0] ) : 0;
}
/**
* Replaces the first safe occurrence of `$anchor` within a window of content.
*
* `$full_content` is always the complete post content; `$segment_start`/
* `$segment_length` mark the byte range to search within, but every
* candidate match is validated against the FULL content so
* {@see HtmlSafetyGuard} can see context outside the window (e.g. a
* sentence-sized window can sit entirely inside a JSON blob without any
* sign of that from the window's own text). Four matching strategies are
* tried in priority order; the guard, not a ``-splitting pre-pass,
* is what keeps existing links untouched.
*
* @since 1.3.0
* @since 1.5.0 Rewritten to search absolute byte offsets in the full
* content and gate every candidate on `HtmlSafetyGuard`,
* replacing the previous ``-tag `preg_split()` pre-pass.
*
* @param string $full_content Complete post content.
* @param int $segment_start Byte offset where the search window starts.
* @param int $segment_length Byte length of the search window.
* @param string $anchor Anchor phrase to find.
* @param string $link_html The `` tag to insert.
*
* @return string|null Modified full content, or null when no safe match was found.
*/
private function insert_into_segment( string $full_content, int $segment_start, int $segment_length, string $anchor, string $link_html ): ?string {
$window_end = $segment_start + $segment_length;
$escaped = preg_quote( $anchor, '/' );
// Unicode-aware word boundaries. `\b` is ASCII-only and misses the
// edges of accented/Turkish text, so an explicit lookaround is used
// instead — it also prevents "cat" from matching inside "category".
$boundary_open = '(?find_first_safe_match( $full_content, $pattern_exact, $segment_start, $window_end );
if ( null !== $match ) {
[ $start, $end ] = $match;
return $this->splice( $full_content, $start, $end, $link_html );
}
// Strategy 2: case-insensitive — preserve original casing inside link.
$pattern_ci = '/' . $boundary_open . $escaped . $boundary_close . '/iu';
$match = $this->find_first_safe_match( $full_content, $pattern_ci, $segment_start, $window_end );
if ( null !== $match ) {
[ $start, $end, $matched_text ] = $match;
$link_with_case = str_replace( $anchor, $matched_text, $link_html );
return $this->splice( $full_content, $start, $end, $link_with_case );
}
// Strategy 3: whitespace-normalised (handles double spaces, , wrapped lines).
$anchor_ws = preg_replace( '/\\\\ /', '\\s+', preg_replace( '/\s+/', '\\ ', $escaped ) );
$pattern_ws = '/' . $boundary_open . $anchor_ws . $boundary_close . '/iu';
$match = $this->find_first_safe_match( $full_content, $pattern_ws, $segment_start, $window_end );
if ( null !== $match ) {
[ $start, $end, $matched_text ] = $match;
$link_with_orig = str_replace( $anchor, $matched_text, $link_html );
return $this->splice( $full_content, $start, $end, $link_with_orig );
}
// Strategy 4: inline-HTML-aware — matches an anchor phrase even when the
// editor wrapped individual words in inline formatting tags, e.g.
// "scottish dog breeds".
$inline_tags = '(?:<\/?\s*(?:strong|em|b|i|u|s|code|span|mark|small|sub|sup)[^>]*>)*';
$anchor_words = preg_split( '/\s+/u', $anchor, -1, PREG_SPLIT_NO_EMPTY );
if ( false === $anchor_words || array() === $anchor_words ) {
return null;
}
$word_patterns = array_map( static fn( $w ) => preg_quote( $w, '/' ), $anchor_words );
if ( count( $word_patterns ) > 1 ) {
$pattern_html = '/' . $boundary_open . implode( $inline_tags . '\s+' . $inline_tags, $word_patterns ) . $boundary_close . '/iu';
} else {
$pattern_html = '/' . $boundary_open . $inline_tags . $word_patterns[0] . $inline_tags . $boundary_close . '/iu';
}
$match = $this->find_first_safe_match( $full_content, $pattern_html, $segment_start, $window_end );
if ( null !== $match ) {
[ $start, $end, $matched_text ] = $match;
$clean_text = wp_strip_all_tags( $matched_text );
$link_clean = str_replace( $anchor, $clean_text, $link_html );
return $this->splice( $full_content, $start, $end, $link_clean );
}
return null;
}
/**
* Finds the first safe regex match inside a byte-offset window.
*
* Repeatedly searches `$pattern` in `$full_content` starting from
* `$window_start`, using `PREG_OFFSET_CAPTURE` plus the `preg_match()`
* `$offset` argument to resume after each attempt without re-scanning
* from the start of the string. A match is only returned once
* {@see HtmlSafetyGuard::is_safe()} accepts it; unsafe matches (inside
* an existing ``, an attribute value, or JSON) are skipped and the
* search continues, rather than giving up on the whole strategy.
*
* @since 1.5.0
*
* @param string $full_content Complete post content the offsets refer to.
* @param string $pattern PCRE pattern to search for.
* @param int $window_start Byte offset where the window starts (inclusive).
* @param int $window_end Byte offset where the window ends (exclusive).
*
* @return array{0:int,1:int,2:string}|null [start, end, matched_text], or
* null when no safe match exists
* inside the window.
*/
private function find_first_safe_match( string $full_content, string $pattern, int $window_start, int $window_end ): ?array {
$search_from = $window_start;
while ( $search_from < $window_end ) {
if ( 1 !== preg_match( $pattern, $full_content, $m, PREG_OFFSET_CAPTURE, $search_from ) ) {
return null;
}
[ $matched_text, $start ] = $m[0];
$end = $start + strlen( $matched_text );
// A match that starts at or after the window, or that runs past
// the window's end, isn't a match "inside" the requested window —
// stop rather than accept text outside the caller's intended span.
if ( $start >= $window_end || $end > $window_end ) {
return null;
}
if ( $this->safety->is_safe( $full_content, $start, $end ) ) {
return array( $start, $end, $matched_text );
}
// Unsafe occurrence (e.g. inside an existing or block-comment
// JSON) — resume the search just past it instead of abandoning
// the strategy on the first hit.
$search_from = $start + max( 1, strlen( $matched_text ) );
}
return null;
}
/**
* Replaces a byte range of content with a literal string.
*
* Plain string concatenation, not `preg_replace()` — the replacement
* text is never interpreted for `$1`/backreference syntax, so anchor
* or link text containing `$` needs no escaping.
*
* @since 1.5.0
*
* @param string $content Content to splice into.
* @param int $start Byte offset where the replaced range starts.
* @param int $end Byte offset where the replaced range ends (exclusive).
* @param string $replacement Text to insert in place of the range.
*
* @return string Resulting content.
*/
private function splice( string $content, int $start, int $end, string $replacement ): string {
return substr( $content, 0, $start ) . $replacement . substr( $content, $end );
}
/**
* Removes a link from content.
*
* Finds the first `` element whose href equals `$url` and replaces
* the entire tag with its inner text. Returns `REASON_LINK_NOT_FOUND`
* when no matching link exists in the content.
*
* Only removes the first match. To remove every occurrence, callers
* loop until {@see LinkInsertionResult::is_success()} is `false`.
*
* @since 1.0.0
*
* @param string $content Post content to modify.
* @param string $url URL of the link to remove (matched against href).
*
* @return LinkInsertionResult
*/
public function remove( string $content, string $url ): LinkInsertionResult {
$content = $this->normalise_input( $content );
$url = $this->normalise_input( $url );
if ( '' === $content || '' === $url ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_EMPTY_INPUT, $content );
}
$tag_info = $this->find_first_tag_with_href( $content, $url );
if ( null === $tag_info ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_LINK_NOT_FOUND, $content );
}
[ $tag_start, $tag_full ] = $tag_info;
$inner_text = $this->parser->get_inner_text( $tag_full );
$new_content = substr( $content, 0, $tag_start )
. $inner_text
. substr( $content, $tag_start + strlen( $tag_full ) );
return LinkInsertionResult::success( $new_content );
}
/**
* Updates the URL of an existing link in content.
*
* Finds the first `` element whose href equals `$old_url` and
* rewrites its href attribute to `$new_url`. Other attributes
* (anchor text, target, rel, data-*) are preserved verbatim.
*
* @since 1.0.0
*
* @param string $content Post content to modify.
* @param string $old_url Current href.
* @param string $new_url Replacement href.
*
* @return LinkInsertionResult
*/
public function update_url( string $content, string $old_url, string $new_url ): LinkInsertionResult {
$content = $this->normalise_input( $content );
$old_url = $this->normalise_input( $old_url );
$new_url = $this->normalise_input( $new_url );
if ( '' === $content || '' === $old_url || '' === $new_url ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_EMPTY_INPUT, $content );
}
if ( $old_url === $new_url ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_NO_CHANGE, $content );
}
$tag_info = $this->find_first_tag_with_href( $content, $old_url );
if ( null === $tag_info ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_LINK_NOT_FOUND, $content );
}
[ $tag_start, $tag_full ] = $tag_info;
// Rewrite only the href attribute inside the matched tag.
$rewritten = preg_replace_callback(
'/(href\s*=\s*)("([^"]*)"|\'([^\']*)\')/i',
static function ( array $m ) use ( $new_url ): string {
$quote = ( '"' === $m[2][0] ) ? '"' : "'";
return $m[1] . $quote . $new_url . $quote;
},
$tag_full,
1
);
if ( null === $rewritten || $rewritten === $tag_full ) {
return LinkInsertionResult::failure( LinkInsertionResult::REASON_LINK_NOT_FOUND, $content );
}
$new_content = substr( $content, 0, $tag_start )
. $rewritten
. substr( $content, $tag_start + strlen( $tag_full ) );
return LinkInsertionResult::success( $new_content );
}
/**
* Locates the first `` tag whose href matches the given URL.
*
* Returns `[byte_offset, full_tag_markup]` on hit, or `null` when no
* matching link exists. Comparison is exact-match on the href value;
* callers wanting trailing-slash-tolerant matching should normalise
* URLs before calling.
*
* @since 1.0.0
*
* @param string $content Content to search.
* @param string $url URL to look for.
*
* @return array{0:int,1:string}|null
*/
private function find_first_tag_with_href( string $content, string $url ): ?array {
if ( ! preg_match_all( '/]*>.*?<\/a>/is', $content, $matches, PREG_OFFSET_CAPTURE ) ) {
return null;
}
foreach ( $matches[0] as $match ) {
[ $tag, $offset ] = $match;
if ( $this->parser->get_href( $tag ) === $url ) {
return array( $offset, $tag );
}
}
return null;
}
/**
* Trims surrounding whitespace (including U+00A0) from input.
*
* @since 1.0.0
*/
private function normalise_input( string $value ): string {
return trim( $value, " \t\n\r\0\x0B\u{00A0}" );
}
}