$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'],
);
}
}