` markup would * corrupt the document: * * - Inside an HTML tag's attribute list (`my SENTENCE`) * - Inside an attribute value (``) * - Inside a JSON blob (Gutenberg block markup) * - Inside an existing `...` (would create nested anchors) * * The guard scans the content and answers each of these questions for a * given offset range. Implementation is regex-based and intentionally * defensive: if a check is uncertain, it returns *unsafe* rather than * risk corrupting content. * * Each check method takes the same parameter shape so callers can wrap * them in a single combined check via {@see is_safe()}. * * @since 1.0.0 */ final class HtmlSafetyGuard { /** * Returns whether the offset range is safe for link insertion. * * Convenience method that runs every individual check and returns * `false` on the first failure. * * @since 1.0.0 * * @param string $content Full content the offsets refer to. * @param int $position_start Byte offset (UTF-8) where the candidate text starts. * @param int $position_end Byte offset where it ends (exclusive). * * @return bool True when insertion at this offset would not corrupt the document. */ public function is_safe( string $content, int $position_start, int $position_end ): bool { if ( $this->is_inside_anchor( $content, $position_start, $position_end ) ) { return false; } if ( $this->is_inside_tag( $content, $position_start, $position_end ) ) { return false; } if ( $this->is_inside_attribute( $content, $position_start, $position_end ) ) { return false; } if ( $this->is_inside_json( $content, $position_start, $position_end ) ) { return false; } if ( $this->is_inside_heading( $content, $position_start, $position_end ) ) { return false; } return true; } /** * Returns whether the offset range is inside an `

`…`

` element. * * Headings name a section rather than carry prose, so a link placed in * one is never what an author intended. The suggestion engine already * refuses to build anchors out of heading text, but two paths reach an * insertion without going through it: {@see LinkInserter::insert_anchor()}, * used as the whole-content fallback when a context sentence cannot be * located verbatim, and the auto-link rules, which match on anchor text * alone. Rejecting the offset here covers both, and a rejected match is * skipped rather than fatal — {@see LinkInserter::find_first_safe_match()} * keeps searching for a safe occurrence elsewhere in the content. * * The `\1` backreference pairs the closing tag with the opening one, so * an unbalanced `

…

` does not extend the guarded span to the * next unrelated heading close. * * @since 1.12.0 * * @param string $content Full content. * @param int $position_start Byte offset where the candidate text starts. * @param int $position_end Byte offset where it ends (exclusive). * * @return bool */ public function is_inside_heading( string $content, int $position_start, int $position_end ): bool { if ( ! $this->offsets_valid( $content, $position_start, $position_end ) ) { return false; } if ( ! preg_match_all( '/]*>.*?<\/h\1>/is', $content, $matches, PREG_OFFSET_CAPTURE ) ) { return false; } foreach ( $matches[0] as $match ) { [ $element, $start ] = $match; $end = $start + strlen( $element ); if ( $position_start >= $start && $position_end <= $end ) { return true; } } return false; } /** * Returns whether the offset range is inside an existing `...` element. * * Inserting a link inside another link would create nested anchors, * which most browsers tolerate but no SEO tool reads correctly. * * @since 1.0.0 * * @param string $content Full content. * @param int $position_start Byte offset where the candidate text starts. * @param int $position_end Byte offset where it ends (exclusive). * * @return bool */ public function is_inside_anchor( string $content, int $position_start, int $position_end ): bool { if ( ! $this->offsets_valid( $content, $position_start, $position_end ) ) { return false; } // Walk every ... in the content and check if our offset falls inside one. // Using PREG_OFFSET_CAPTURE so we get byte offsets for each match. if ( ! preg_match_all( '/]*>.*?<\/a>/is', $content, $matches, PREG_OFFSET_CAPTURE ) ) { return false; } foreach ( $matches[0] as $match ) { [ $tag, $start ] = $match; $end = $start + strlen( $tag ); if ( $position_start >= $start && $position_end <= $end ) { return true; } } return false; } /** * Returns whether the offset range is inside an HTML tag's attribute list. * * "Inside the tag" means between ``, * but outside the tag's text content. The check is: is there an * unmatched `<` somewhere before the offset, with no `>` between? * * @since 1.0.0 * * @param string $content Full content. * @param int $position_start Byte offset where the candidate text starts. * @param int $position_end Byte offset where it ends (exclusive). * * @return bool */ public function is_inside_tag( string $content, int $position_start, int $position_end ): bool { if ( ! $this->offsets_valid( $content, $position_start, $position_end ) ) { return false; } // Look at the prefix that precedes our candidate position. $prefix = substr( $content, 0, $position_start ); $last_open = strrpos( $prefix, '<' ); $last_close = strrpos( $prefix, '>' ); if ( false === $last_open ) { // No opening `<` ever — definitely not inside a tag. return false; } if ( false !== $last_close && $last_close > $last_open ) { // The most recent `>` came after the most recent `<` — we're outside. return false; } // We have an unclosed `<` before our offset. Now check that the *suffix* // has a closing `>` before any opening `<`, otherwise the `<` we found // might just be stray text (e.g. user wrote a literal "<3"). $suffix = substr( $content, $position_end ); $next_close = strpos( $suffix, '>' ); $next_open = strpos( $suffix, '<' ); if ( false === $next_close ) { // No closing `>` after — the `<` was probably stray text. return false; } if ( false !== $next_open && $next_open < $next_close ) { // Another `<` came before the `>` — stray text, not a tag. return false; } return true; } /** * Returns whether the offset range is inside an HTML attribute value. * * Walks every `name="value"` (or `name='value'`) attribute in the * content and checks whether the offset range falls between the * quotes. Captures byte offsets via PREG_OFFSET_CAPTURE so the check * is exact. * * @since 1.0.0 * * @param string $content Full content. * @param int $position_start Byte offset where the candidate text starts. * @param int $position_end Byte offset where it ends (exclusive). * * @return bool */ public function is_inside_attribute( string $content, int $position_start, int $position_end ): bool { if ( ! $this->offsets_valid( $content, $position_start, $position_end ) ) { return false; } // Match attribute pairs anywhere in the content. // Format: optional whitespace, name, =, then a quoted value. // We capture the value position so we can check the range. if ( ! preg_match_all( '/[a-zA-Z][a-zA-Z0-9_:-]*\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/', $content, $matches, PREG_OFFSET_CAPTURE ) ) { return false; } foreach ( $matches[0] as $i => $match ) { [ , $start ] = $match; $value_match = $matches[1][ $i ][1] >= 0 ? $matches[1][ $i ] : $matches[2][ $i ]; [ $value, $value_start ] = $value_match; $value_end = $value_start + strlen( $value ); if ( $position_start >= $value_start && $position_end <= $value_end ) { return true; } unset( $start ); // not used; kept for documentation of the pattern. } return false; } /** * Returns whether the offset range is inside a JSON blob. * * Gutenberg block markup embeds JSON in HTML comments * (``). Inserting a link inside that JSON * corrupts the block. We detect such regions by scanning for HTML * comments that contain `{` followed by valid JSON. * * Uses a balanced-brace regex (PCRE recursion) to find candidate * JSON objects, then validates each candidate with `json_decode`. * * @since 1.0.0 * * @param string $content Full content. * @param int $position_start Byte offset where the candidate text starts. * @param int $position_end Byte offset where it ends (exclusive). * * @return bool */ public function is_inside_json( string $content, int $position_start, int $position_end ): bool { if ( ! $this->offsets_valid( $content, $position_start, $position_end ) ) { return false; } // Quick exit: no `{` in content means no JSON to worry about. if ( false === strpos( $content, '{' ) ) { return false; } // Find balanced `{...}` candidates with offsets. // (?:[^{}]|(?R)) matches non-brace chars or recursively-balanced subgroups. $pattern = '/\{(?:[^{}]|(?R))*\}/s'; if ( ! preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) { return false; } foreach ( $matches[0] as $match ) { [ $candidate, $start ] = $match; $end = $start + strlen( $candidate ); if ( $position_start <= $start || $position_end > $end ) { continue; // Range is not inside this candidate. } // Validate that the candidate is real JSON (not just a brace soup). json_decode( $candidate ); if ( JSON_ERROR_NONE === json_last_error() ) { return true; } // Try with stripslashes — Gutenberg sometimes serialises with escaped quotes. json_decode( stripslashes( $candidate ) ); if ( JSON_ERROR_NONE === json_last_error() ) { return true; } } return false; } /** * Validates that the given offsets make sense for the content. * * @since 1.0.0 * * @return bool */ private function offsets_valid( string $content, int $start, int $end ): bool { if ( '' === $content ) { return false; } if ( $start < 0 || $end < 0 ) { return false; } if ( $start > $end ) { return false; } if ( $end > strlen( $content ) ) { return false; } return true; } }