` 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}" ); } }