$map Normalized old URL => new URL, or null to unlink. * @param string $base Base URL for resolving relative values. * @return array{html:string,count:int} */ public static function rewrite( string $html, array $map, string $base ): array { if ( '' === $html || empty( $map ) || ( false === stripos( $html, 'href' ) && false === stripos( $html, 'src' ) ) ) { return array( 'html' => $html, 'count' => 0, ); } $token = substr( md5( uniqid( '', true ) ), 0, 12 ); $processor = new \WP_HTML_Tag_Processor( $html ); $count = 0; $unlinks = 0; while ( $processor->next_tag() ) { $tag = strtolower( (string) $processor->get_tag() ); if ( ! isset( HtmlParser::ELEMENTS[ $tag ] ) ) { continue; } $attribute = HtmlParser::ELEMENTS[ $tag ]; $value = $processor->get_attribute( $attribute ); if ( ! is_string( $value ) ) { continue; } $normalized = Url::normalize( $value, $base, false ); if ( null === $normalized || ! array_key_exists( $normalized, $map ) ) { continue; } $new = $map[ $normalized ]; if ( null === $new ) { if ( 'a' === $tag ) { $processor->set_attribute( self::UNLINK_ATTR, $token ); ++$unlinks; } continue; } $processor->set_attribute( $attribute, $new ); ++$count; } $out = $processor->get_updated_html(); if ( $unlinks > 0 ) { $result = self::unwrap_marked( $out, $token ); $out = $result['html']; $count += $result['count']; } return array( 'html' => $out, 'count' => $count, ); } /** * Remove tags carrying the marker attribute but keep their inner content. * * The tag processor inserts new attributes directly after the tag name, so the marker sits at * " closes it. * * @param string $html HTML with markers. * @param string $token Marker value. * @return array{html:string,count:int} */ private static function unwrap_marked( string $html, string $token ): array { $needle = self::UNLINK_ATTR . '="' . $token . '"'; $count = 0; $offset = 0; while ( false !== ( $pos = strpos( $html, $needle, $offset ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition $open_start = strrpos( substr( $html, 0, $pos ), '<' ); if ( false === $open_start ) { $offset = $pos + strlen( $needle ); continue; } $open_end = self::tag_end( $html, $pos ); if ( null === $open_end ) { break; } $close_start = self::find_closing_a( $html, $open_end + 1 ); if ( null === $close_start ) { // No closing tag: drop only the opening tag. $html = substr( $html, 0, $open_start ) . substr( $html, $open_end + 1 ); } else { $close_end = strpos( $html, '>', $close_start ); $close_end = false === $close_end ? $close_start + 3 : $close_end; $inner = substr( $html, $open_end + 1, $close_start - $open_end - 1 ); $html = substr( $html, 0, $open_start ) . $inner . substr( $html, $close_end + 1 ); } $offset = $open_start; ++$count; } return array( 'html' => $html, 'count' => $count, ); } /** * Position of the ">" that ends the tag containing $from, honouring quoted attribute values. * * @param string $html HTML. * @param int $from Position inside the tag. */ private static function tag_end( string $html, int $from ): ?int { $quote = ''; $length = strlen( $html ); for ( $i = $from; $i < $length; $i++ ) { $c = $html[ $i ]; if ( '' !== $quote ) { if ( $c === $quote ) { $quote = ''; } continue; } if ( '"' === $c || "'" === $c ) { $quote = $c; continue; } if ( '>' === $c ) { return $i; } } return null; } /** * Start of the next "" closing tag (case-insensitive) at or after $from. * * @param string $html HTML. * @param int $from Offset. */ private static function find_closing_a( string $html, int $from ): ?int { $length = strlen( $html ); while ( $from < $length ) { $pos = stripos( $html, '' === $next || ctype_space( $next ) ) { return $pos; } $from = $pos + 3; } return null; } /** * Replace URLs in page-builder shortcode attributes (e.g. Divi `button_url="..."`). * Unlinking empties the attribute value. * * @param string $content Content. * @param array $map Normalized old URL => new URL or null. * @param string $base Base URL. * @return array{html:string,count:int} */ public static function rewrite_shortcodes( string $content, array $map, string $base ): array { $count = 0; if ( false === strpos( $content, '[' ) || empty( $map ) ) { return array( 'html' => $content, 'count' => 0, ); } $content = (string) preg_replace_callback( '/\[(?![\/\[])([a-zA-Z][a-zA-Z0-9_\-]*)(\s[^\]]*?)?\/?\]/', static function ( $m ) use ( $map, $base, &$count ) { if ( empty( $m[2] ) ) { return $m[0]; } $atts = (string) preg_replace_callback( '/([a-zA-Z0-9_\-]+)(\s*=\s*)(?:"([^"]*)"|\'([^\']*)\')/', static function ( $a ) use ( $map, $base, &$count ) { $key = $a[1]; $quote = isset( $a[4] ) && '' !== $a[4] ? "'" : '"'; $value = '"' === $quote ? $a[3] : $a[4]; if ( ! preg_match( '/(?:^|_)(?:url|link|href|src)$/i', $key ) || ! AttributeParser::looks_like_url( $value ) ) { return $a[0]; } $normalized = Url::normalize( $value, $base ); if ( null === $normalized || ! array_key_exists( $normalized, $map ) ) { return $a[0]; } ++$count; $new = null === $map[ $normalized ] ? '' : str_replace( array( '"', "'", ']' ), array( '%22', '%27', '%5D' ), (string) $map[ $normalized ] ); return $key . $a[2] . $quote . $new . $quote; }, $m[2] ); return str_replace( $m[2], $atts, $m[0] ); }, $content ); return array( 'html' => $content, 'count' => $count, ); } /** * Replace URLs in Gutenberg block comment attributes (embeds, file blocks...). Only the attribute * JSON of affected blocks is re-encoded, with core's own serializer. Unlinking is not applied to * block attributes (an embed without a URL makes no sense); those instances are reported as skipped. * * @param string $content Content. * @param array $map Normalized old URL => new URL (unlinks excluded). * @param string $base Base URL. * @return array{html:string,count:int} */ public static function rewrite_block_attributes( string $content, array $map, string $base ): array { $count = 0; if ( false === strpos( $content, ').)*?\})\s+-->(.*?)/s', static function ( $m ) use ( $map, $base, &$count ) { $attrs = json_decode( $m[1], true ); if ( ! is_array( $attrs ) || ! isset( $attrs['url'] ) || ! is_string( $attrs['url'] ) ) { return $m[0]; } $old = $attrs['url']; $normalized = Url::normalize( $old, $base ); if ( null === $normalized || ! isset( $map[ $normalized ] ) ) { return $m[0]; } $attrs['url'] = $map[ $normalized ]; ++$count; $inner = str_replace( array( $old, esc_html( $old ) ), esc_html( $map[ $normalized ] ), $m[2] ); return str_replace( array( $m[1], $m[2] ), array( serialize_block_attributes( $attrs ), $inner ), $m[0] ); }, $content ); $content = (string) preg_replace_callback( '/).)*?\})\s+(\/)?-->/s', static function ( $m ) use ( $map, $base, &$count ) { $attrs = json_decode( $m[3], true ); if ( ! is_array( $attrs ) ) { return $m[0]; } $changed = false; foreach ( $attrs as $key => $value ) { if ( ! is_string( $key ) || ! is_string( $value ) || ! preg_match( '/^(?:url|href|link|src)$|[a-z](?:Url|Link|Href|Src)$/', $key ) ) { continue; } $normalized = Url::normalize( $value, $base ); if ( null !== $normalized && isset( $map[ $normalized ] ) ) { $attrs[ $key ] = $map[ $normalized ]; $changed = true; ++$count; } } if ( ! $changed ) { return $m[0]; } return str_replace( $m[3], serialize_block_attributes( $attrs ), $m[0] ); }, $content ); return array( 'html' => $content, 'count' => $count, ); } /** * Full post_content rewrite: HTML attributes, shortcode attributes, block attributes. * * @param string $content Content. * @param array $map Normalized old URL => new URL or null. * @param string $base Base URL. * @return array{html:string,count:int} */ public static function rewrite_content( string $content, array $map, string $base ): array { $html = self::rewrite( $content, $map, $base ); $shortcodes = self::rewrite_shortcodes( $html['html'], $map, $base ); $replace = array_filter( $map, static function ( $v ) { return null !== $v; } ); $blocks = self::rewrite_block_attributes( $shortcodes['html'], $replace, $base ); return array( 'html' => $blocks['html'], 'count' => $html['count'] + $shortcodes['count'] + $blocks['count'], ); } }