(wpautop sites),

without *

, cards and boxes from other plugins, page-builder shortcodes that wrap * the text ([vc_column_text], [et_pb_text] …), block comments, scripts. A new * See-also card must land in the article's own flow, never inside one of those * boxes; a Text Match link never inside code, a script or a button. * * Depth = how many "boxes" surround a byte offset: * - HTML boxes (BOX_TAGS) — measured with WordPress's own HTML parser * (WP_HTML_Processor, 6.5+) when it is there: an invisible probe comment is * put at every offset in a copy, the copy is parsed once, and each probe's * breadcrumbs say what it sits in — the browser's view, including implied * closing tags. A probe the parser never reaches (inside a script, a * textarea, an attribute) is "unknown" and never allowed. Without the * parser (older WordPress) or when it gives up, a tag-counting fallback. * - Enclosing shortcodes ([x]…[/x]) — one level each; a self-closing one * ([gallery id="1"]) has no inside and adds nothing. * * Covered by tests/insertion.php (Pro zip only), run with `wp eval-file`. */ final class HtmlPlaces { /** Elements that make a box: something inside one is not in the article's own flow. */ public const BOX_TAGS = [ 'DIV', 'BLOCKQUOTE', 'ASIDE', 'SECTION', 'FIGURE', 'FIGCAPTION', 'TABLE', 'UL', 'OL', 'LI', 'DL', 'DETAILS', 'SUMMARY', 'NAV', 'HEADER', 'FOOTER', 'ARTICLE', 'FORM', 'PRE', 'CODE', 'A', 'BUTTON', 'LABEL', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'IFRAME', 'VIDEO', 'AUDIO', 'OBJECT', 'SELECT', 'SVG', 'MATH' ]; /** Depth for an offset that cannot be placed (inside a script, a tag, an attribute …). */ public const UNKNOWN = 999; private const PROBE = 'clusterlane-probe:'; /** Tests only: false forces the tag-counting fallback (as on WordPress < 6.5). */ public static bool $use_parser = true; /** * Box depth at each byte offset. * @param int[] $offsets * @return array same keys as $offsets */ public static function depths( string $html, array $offsets ): array { if ( ! $offsets ) return []; $html_d = self::parser_depths( $html, $offsets ) ?? self::regex_depths( $html, $offsets ); $sc = self::shortcode_ranges( $html ); $nogo = self::no_go_ranges( $html ); $out = []; foreach ( $offsets as $k => $off ) { $d = $html_d[ $k ] ?? self::UNKNOWN; foreach ( $nogo as [ $s, $e ] ) if ( $off > $s && $off < $e ) $d = self::UNKNOWN; // in a script, code, comment … if ( $d < self::UNKNOWN ) { foreach ( $sc as [ $s, $e ] ) if ( $off >= $s && $off <= $e ) $d++; // inner range, both ends inside if ( self::inside_tag( $html, $off ) ) $d = self::UNKNOWN; } $out[ $k ] = $d; } return $out; } public static function depth_at( string $html, int $offset ): int { return self::depths( $html, [ $offset ] )[0]; } /** * The article's own flow among candidate places (pairs [text, offset] as * preg_match_all gives them): the ones at the shallowest depth. When there * is article text outside every box, only depth 0 counts — a lone

in a * card is not the article. A page wrapped in one

or one builder * shortcode keeps its shallowest level. * * @param array $places * @return array */ public static function top_level( string $html, array $places ): array { if ( ! $places ) return []; $depths = self::depths( $html, array_map( fn( $p ) => (int) $p[1], $places ) ); $known = array_filter( $depths, fn( $d ) => $d < self::UNKNOWN ); if ( ! $known ) return []; $min = min( $known ); if ( $min > 0 && self::outside_text_len( $html ) > 40 ) return []; $keep = []; foreach ( $places as $i => $p ) if ( $depths[ $i ] === $min ) $keep[] = $p; return $keep; } /** The shallowest depth the article's text sits at (0 unless everything is wrapped). */ public static function flow_depth( string $html ): int { if ( self::outside_text_len( $html ) > 40 ) return 0; preg_match_all( '/]*>/i', $html, $m, PREG_OFFSET_CAPTURE ); $known = array_filter( self::depths( $html, array_map( fn( $x ) => $x[1], $m[0] ) ), fn( $d ) => $d < self::UNKNOWN ); return $known ? min( $known ) : 0; } /** * Byte ranges where nothing may be linked or inserted: scripts, styles, * code, preformatted text, form controls, buttons and HTML comments * (block comments carry JSON attributes). * @return array */ public static function no_go_ranges( string $html ): array { $out = []; foreach ( [ '/<(script|style|code|pre|textarea|button|select|svg)\b[^>]*>.*?<\/\1>/is', '//s' ] as $rx ) { if ( preg_match_all( $rx, $html, $m, PREG_OFFSET_CAPTURE ) ) { foreach ( $m[0] as $r ) $out[] = [ $r[1], $r[1] + strlen( $r[0] ) ]; } } return $out; } /* ------------------------------------------------------------------ Internals ------------------------------------------------------------------ */ /** Offsets between < and > of a tag (not a comment) are never a place. */ private static function inside_tag( string $html, int $off ): bool { $before = substr( $html, 0, $off ); $lo = strrpos( $before, '<' ); $lc = strrpos( $before, '>' ); return $lo !== false && ( $lc === false || $lo > $lc ); } /** @return array|null null: parser missing or it gave up. */ private static function parser_depths( string $html, array $offsets ): ?array { if ( ! self::$use_parser || ! class_exists( '\WP_HTML_Processor' ) || ! method_exists( '\WP_HTML_Processor', 'next_token' ) || ! method_exists( '\WP_HTML_Processor', 'get_breadcrumbs' ) ) { return null; } // A copy with a probe at every offset, inserted from the back so offsets stay valid. $order = $offsets; arsort( $order ); $probed = $html; foreach ( $order as $k => $off ) { $probed = substr( $probed, 0, $off ) . '' . substr( $probed, $off ); } try { $p = \WP_HTML_Processor::create_fragment( $probed ); if ( ! $p ) return null; $box = array_flip( self::BOX_TAGS ); $found = []; while ( $p->next_token() ) { if ( $p->get_token_type() !== '#comment' ) continue; $t = $p->get_modifiable_text(); if ( ! str_starts_with( $t, self::PROBE ) ) continue; $n = 0; foreach ( $p->get_breadcrumbs() as $tag ) if ( isset( $box[ $tag ] ) ) $n++; $found[ substr( $t, strlen( self::PROBE ) ) ] = $n; } if ( $p->get_last_error() !== null ) return null; // unsupported markup: use the fallback } catch ( \Throwable $e ) { return null; } $out = []; foreach ( $offsets as $k => $off ) $out[ $k ] = $found[ (string) $k ] ?? self::UNKNOWN; return $out; } /** Tag counting (no parser): opening minus closing box tags before each offset. */ private static function regex_depths( string $html, array $offsets ): array { $names = implode( '|', array_map( 'strtolower', self::BOX_TAGS ) ); preg_match_all( '#<(/?)(' . $names . ')\b[^>]*?(/?)>#i', $html, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ); $out = []; foreach ( $offsets as $k => $off ) { $d = 0; foreach ( $tags as $t ) { if ( $t[0][1] >= $off ) break; if ( $t[3][0] === '/' ) continue; $d = max( 0, $d + ( $t[1][0] === '/' ? -1 : 1 ) ); } $out[ $k ] = $d; } return $out; } /** * Inner byte ranges [after the opening tag, before the closing tag) of * every enclosing shortcode, nested ones included. * @return array */ public static function shortcode_ranges( string $html ): array { if ( ! preg_match_all( '/\[(\/)?([a-zA-Z][\w-]*)(?:\s[^\]]*)?(\/)?\]/', $html, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) return []; $out = []; $stack = []; foreach ( $m as $t ) { $name = strtolower( $t[2][0] ); $close = $t[1][0] === '/'; if ( ! $close ) { if ( isset( $t[3] ) && $t[3][0] === '/' ) continue; // [x /] if ( stripos( $html, '[/' . $name . ']', $t[0][1] ) === false ) continue; // self-closing $stack[] = [ $name, $t[0][1] + strlen( $t[0][0] ) ]; continue; } for ( $i = count( $stack ) - 1; $i >= 0; $i-- ) { if ( $stack[ $i ][0] !== $name ) continue; $out[] = [ $stack[ $i ][1], $t[0][1] ]; array_splice( $stack, $i ); break; } } return $out; } /** Characters of article text outside every box and enclosing shortcode. */ private static function outside_text_len( string $html ): int { // Remove enclosing shortcodes (outermost first: longest ranges cover the nested ones). $sc = self::shortcode_ranges( $html ); usort( $sc, fn( $a, $b ) => $b[0] <=> $a[0] ); $text = $html; foreach ( $sc as [ $s, $e ] ) { if ( $e <= strlen( $text ) ) $text = substr( $text, 0, $s ) . str_repeat( ' ', $e - $s ) . substr( $text, $e ); } // Then everything inside box elements (tag counting is enough for a length estimate). $names = implode( '|', array_map( 'strtolower', self::BOX_TAGS ) ); preg_match_all( '#<(/?)(' . $names . ')\b[^>]*?(/?)>#i', $text, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ); $outside = ''; $d = 0; $last = 0; foreach ( $tags as $t ) { if ( $d === 0 ) $outside .= substr( $text, $last, $t[0][1] - $last ); if ( $t[3][0] !== '/' ) $d = max( 0, $d + ( $t[1][0] === '/' ? -1 : 1 ) ); $last = $t[0][1] + strlen( $t[0][0] ); } if ( $d === 0 ) $outside .= substr( $text, $last ); $outside = preg_replace( '/|\[[^\]]*\]/s', ' ', $outside ); $outside = trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( (string) $outside ) ) ); return function_exists( 'mb_strlen' ) ? mb_strlen( $outside, 'UTF-8' ) : strlen( $outside ); } }