diff --git a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php index 5c0452afe..fbfd62c10 100644 --- a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php +++ b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php @@ -351,6 +351,11 @@ public function set_url( $raw_url, $parsed_url ) { if ( null === $this->raw_url ) { return false; } + if ( $raw_url === $this->raw_url ) { + // A relative URL may resolve against a new base without changing its source spelling. + $this->parsed_url = $parsed_url; + return true; + } $this->raw_url = $raw_url; $this->parsed_url = $parsed_url; switch ( parent::get_token_type() ) { @@ -384,14 +389,15 @@ public function set_url( $raw_url, $parsed_url ) { /** * Rewrites the components of the currently matched URL from ones - * provided in $from_url to ones specified in $to_url. + * provided in $base_url to ones specified in $to_url. * - * It preserves the relative nature of the matched URL. + * Structured values retain unmatched decoded URL bytes and their relative + * nature. Text nodes retain their complete-value behavior. * * @TODO: Should this method live in this class? It's specific to the import process * and the URL rewriting logic and has knowledge about the quirks of detecting - * relative URLs in text nodes. On the other hand, the detection is performed - * by this WPURL_In_Text_Processor class so maybe the two do go hand in hand? + * relative URLs in text nodes. On the other hand, URLInTextProcessor performs + * that detection, so maybe the two do go hand in hand? */ public function replace_base_url( $to_url, $base_url = null ) { $base_url = $base_url ?? $this->base_url_object; @@ -421,10 +427,14 @@ public function replace_base_url( $to_url, $base_url = null ) { if ( false === $result ) { return false; } + if ( '#text' === parent::get_token_type() ) { + return $this->set_url( (string) $result, $result->new_url ); + } + if ( null === $result->new_source_preserving_raw_url ) { + return false; + } - $this->set_url( $result . '', $result->new_url ); - - return true; + return $this->set_url( $result->new_source_preserving_raw_url, $result->new_url ); } /** diff --git a/components/DataLiberation/CSS/class-cssprocessor.php b/components/DataLiberation/CSS/class-cssprocessor.php index e54f88864..0b32b77c4 100644 --- a/components/DataLiberation/CSS/class-cssprocessor.php +++ b/components/DataLiberation/CSS/class-cssprocessor.php @@ -888,23 +888,36 @@ public function set_token_value( string $new_value ): bool { // Only URL and string tokens are currently supported. switch ( $this->token_type ) { case self::TOKEN_URL: - $this->lexical_updates[] = array( - 'start' => $this->token_value_starts_at, - 'length' => $this->token_value_length, - 'text' => $this->escape_url_value( $new_value ), - ); - return true; + $update_start = $this->token_value_starts_at; + $update_length = $this->token_value_length; + break; case self::TOKEN_STRING: - $this->lexical_updates[] = array( - 'start' => $this->token_starts_at, - 'length' => $this->token_length, - 'text' => $this->escape_url_value( $new_value ), - ); - return true; + $update_start = $this->token_starts_at; + $update_length = $this->token_length; + break; default: _doing_it_wrong( __METHOD__, 'set_token_value() only supports URL and string tokens. Got token type: ' . $this->token_type, '1.0.0' ); return false; } + + $escaped_value = $this->escape_url_value( $new_value ); + $last_update_index = count( $this->lexical_updates ) - 1; + // A later write to the current token supersedes its pending update. + if ( + 0 <= $last_update_index && + $update_start === $this->lexical_updates[ $last_update_index ]['start'] && + $update_length === $this->lexical_updates[ $last_update_index ]['length'] + ) { + $this->lexical_updates[ $last_update_index ]['text'] = $escaped_value; + return true; + } + + $this->lexical_updates[] = array( + 'start' => $update_start, + 'length' => $update_length, + 'text' => $escaped_value, + ); + return true; } /** diff --git a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php index 22193d9fe..f8b3d65e9 100644 --- a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php +++ b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php @@ -196,7 +196,7 @@ public static function provider_test_set_url_examples() { 'In the "url" block attribute of a navigation-link block' => array( '', 'https://w.org', - '', + '', ), 'In a text node' => array( 'Have you seen https://wordpress.org yet?', @@ -305,6 +305,76 @@ public static function provider_test_next_url_replace_base_url() { ); } + /** + * @dataProvider provider_replace_base_url_in_structured_values + */ + public function test_replace_base_url_in_structured_values( $markup, $expected ) { + $p = new BlockMarkupUrlProcessor( $markup, 'http://old.example/media' ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://new.example/assets' ) ); + $this->assertSame( $expected, $p->get_updated_html() ); + } + + public static function provider_replace_base_url_in_structured_values() { + return array( + 'HTML attribute' => array( + '', + '', + ), + 'CSS URL' => array( + '
', + '', + ), + 'block attribute' => array( + '', + '', + ), + ); + } + + public function test_replace_base_url_updates_parsed_url_without_a_source_replacement() { + $markup = ''; + $p = new BlockMarkupUrlProcessor( $markup, 'http://old.example/media' ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://old.example/media' ) ); + $this->assertSame( 'https://old.example/media/file', $p->get_parsed_url()->toString() ); + $this->assertSame( $markup, $p->get_updated_html() ); + } + + public function test_replace_base_url_rewrites_later_css_urls_after_rendering_an_earlier_update() { + $markup = ''; + $p = new BlockMarkupUrlProcessor( $markup, 'http://very-long-old.example/very/long/base' ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://n.example/x' ) ); + $p->get_updated_html(); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://n.example/x' ) ); + $this->assertSame( + '', + $p->get_updated_html() + ); + } + + public function test_set_url_replaces_a_pending_css_base_update() { + $p = new BlockMarkupUrlProcessor( + '', + 'http://old.example/media' + ); + + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->next_url() ); + $this->assertTrue( $p->replace_base_url( 'https://new.example/assets' ) ); + $this->assertTrue( $p->set_url( 'https://other.example/file', WPURL::parse( 'https://other.example/file' ) ) ); + $this->assertSame( + '', + $p->get_updated_html() + ); + } + /** * @dataProvider provider_test_css_url_detection */ diff --git a/components/DataLiberation/Tests/CSSProcessorTest.php b/components/DataLiberation/Tests/CSSProcessorTest.php index 965f9e5a3..30b79c1bf 100644 --- a/components/DataLiberation/Tests/CSSProcessorTest.php +++ b/components/DataLiberation/Tests/CSSProcessorTest.php @@ -1615,7 +1615,7 @@ public function test_set_token_value_handles_emoji(): void { } /** - * Tests that multiple URL values can be updated in the same CSS. + * Tests that multiple URL values can be updated and the last update to a token wins. */ public function test_set_token_value_multiple_urls(): void { $css = 'background: url(old1.jpg); border-image: url(old2.png);'; @@ -1628,6 +1628,7 @@ public function test_set_token_value_multiple_urls(): void { if ( 1 === $url_count ) { $processor->set_token_value( 'new1.jpg' ); } elseif ( 2 === $url_count ) { + $processor->set_token_value( 'discarded.png' ); $processor->set_token_value( 'new2.png' ); } } diff --git a/components/DataLiberation/Tests/RewriteUrlsTest.php b/components/DataLiberation/Tests/RewriteUrlsTest.php index 17aca6d57..8babab2c0 100644 --- a/components/DataLiberation/Tests/RewriteUrlsTest.php +++ b/components/DataLiberation/Tests/RewriteUrlsTest.php @@ -5,6 +5,36 @@ use function WordPress\DataLiberation\URL\wp_rewrite_urls; class RewriteUrlsTest extends TestCase { + public function test_structured_urls_use_the_selected_mapping_base() { + $this->assertSame( + '', + wp_rewrite_urls( + array( + 'block_markup' => '', + 'base_url' => 'http://old.example/root/first/', + 'url-mapping' => array( + 'http://old.example/root/first/' => 'https://first.example/first/', + 'http://old.example/root/second/' => 'https://second.example/assets/', + ), + ) + ) + ); + } + + public function test_text_urls_keep_the_configured_base_url_semantics() { + $this->assertSame( + 'https://new.example/assets/sub/file
', + wp_rewrite_urls( + array( + 'block_markup' => 'http://old.example/root/sub/file
', + 'base_url' => 'http://old.example/root/', + 'url-mapping' => array( + 'http://old.example/root/sub/' => 'https://new.example/assets/', + ), + ) + ) + ); + } /** * diff --git a/components/DataLiberation/Tests/WPURLTest.php b/components/DataLiberation/Tests/WPURLTest.php index 337e79491..3de4eb9a6 100644 --- a/components/DataLiberation/Tests/WPURLTest.php +++ b/components/DataLiberation/Tests/WPURLTest.php @@ -87,4 +87,176 @@ public static function provider_test_replace_base_url_trailing_slash() { ), ); } + + /** + * @dataProvider provider_replace_base_url_source_preserving_raw_url + */ + public function test_replace_base_url_returns_source_preserving_raw_url( + $raw_url, + $old_base_url, + $new_base_url, + $expected_raw_url, + $include_raw_url = true + ) { + $url = WPURL::parse( $raw_url, $old_base_url ); + $options = array( + 'old_base_url' => $old_base_url, + 'new_base_url' => $new_base_url, + 'is_relative' => ! WPURL::can_parse( $raw_url ), + ); + if ( $include_raw_url ) { + $options['raw_url'] = $raw_url; + } + $result = WPURL::replace_base_url( + $url, + $options + ); + + $this->assertNotFalse( $result ); + $this->assertSame( $expected_raw_url, $result->new_source_preserving_raw_url ); + if ( null === $expected_raw_url ) { + return; + } + + $this->assertSame( + WPURL::parse( $expected_raw_url, $new_base_url )->toString(), + $result->new_url->toString() + ); + } + + public static function provider_replace_base_url_source_preserving_raw_url() { + return array( + 'absolute' => array( + 'http://old.example/media/file?x=1#section', + 'http://old.example/media', + 'https://new.example/assets', + 'https://new.example/assets/file?x=1#section', + ), + 'protocol-relative' => array( + '//old.example/media/file', + 'http://old.example/media', + 'https://new.example/assets', + '//new.example/assets/file', + ), + 'root-relative' => array( + '/media/file', + 'http://old.example/media', + 'https://new.example/assets', + '/assets/file', + ), + 'percent-encoded source segment' => array( + '/m%65dia/file', + 'http://old.example/media', + 'https://new.example/assets', + '/assets/file', + ), + 'path-relative' => array( + 'file', + 'http://old.example/', + 'https://new.example/assets/', + '/assets/file', + ), + 'query-only' => array( + '?x=1', + 'http://old.example/media', + 'https://new.example/assets', + '/assets/?x=1', + ), + 'empty relative URL' => array( + '', + 'http://old.example/media/', + 'https://new.example/assets/', + '/assets/', + ), + 'exact absolute base' => array( + 'http://old.example/media', + 'http://old.example/media', + 'https://new.example/assets', + 'https://new.example/assets', + ), + 'explicit default port' => array( + 'http://old.example:80/media/file', + 'http://old.example/media', + 'https://old.example/assets', + 'https://old.example/assets/file', + ), + 'non-default source port' => array( + 'http://old.example:81/media/file', + 'http://old.example/media', + 'https://new.example/assets', + 'https://new.example/assets/file', + ), + 'no-op keeps same input' => array( + 'http://OLD.example/media/%7euser', + 'http://old.example/media', + 'http://old.example/media', + 'http://OLD.example/media/%7euser', + ), + 'file URL' => array( + 'file://old.example/media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'explicit scheme without slashes' => array( + 'http:media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'parent-directory-relative path' => array( + '../file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'backslashes in mapped components' => array( + 'http://old.example\\media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + ), + 'encoded slash at the mapped boundary' => array( + '/media%2Fa', + 'http://old.example/media', + 'https://new.example/', + null, + ), + 'parent segment hides a different lexical base' => array( + 'http://old.example/x/../media/file', + 'http://old.example/media', + 'http://old.example/foo/media', + null, + ), + 'encoded slash and dot hide different lexical segments' => array( + '/a%2Fb/./file', + 'http://old.example/a/b', + 'https://new.example/assets', + null, + ), + 'raw URL not supplied' => array( + 'http://old.example/media/file', + 'http://old.example/media', + 'https://new.example/assets', + null, + false, + ), + ); + } + + public function test_replace_base_url_source_preserving_raw_url_follows_the_semantic_result() { + $raw_url = 'http://user@old.example/media/file'; + $result = WPURL::replace_base_url( + WPURL::parse( $raw_url ), + array( + 'old_base_url' => 'http://old.example/media', + 'new_base_url' => 'https://new.example/assets', + 'raw_url' => $raw_url, + 'is_relative' => false, + ) + ); + + $this->assertNotFalse( $result ); + $this->assertSame( (string) $result, $result->new_source_preserving_raw_url ); + } } diff --git a/components/DataLiberation/URL/class-convertedurl.php b/components/DataLiberation/URL/class-convertedurl.php index 1a12dade6..abcfe5ceb 100644 --- a/components/DataLiberation/URL/class-convertedurl.php +++ b/components/DataLiberation/URL/class-convertedurl.php @@ -22,6 +22,14 @@ class ConvertedUrl { /** @var string|null */ public $new_raw_relative_url; + /** + * Updated decoded URL with only its base spelling changed, or null when the + * source spelling could not be aligned safely. + * + * @var string|null + */ + public $new_source_preserving_raw_url = null; + /** @var bool */ public $was_relative = false; diff --git a/components/DataLiberation/URL/class-wpurl.php b/components/DataLiberation/URL/class-wpurl.php index b12013617..61cb1d3b4 100644 --- a/components/DataLiberation/URL/class-wpurl.php +++ b/components/DataLiberation/URL/class-wpurl.php @@ -230,11 +230,177 @@ public static function replace_base_url( $url, $options ) { $converted_url->was_relative = true; $converted_url->new_raw_relative_url = $relative_url; } + + $raw_url_base_replacements = self::create_raw_url_base_replacements( + $options['raw_url'], + $url, + $updated_url, + $old_base_url, + $new_base_url + ); + if ( null !== $raw_url_base_replacements ) { + $source_preserving_raw_url = $options['raw_url']; + foreach ( array_reverse( $raw_url_base_replacements ) as $replacement ) { + $source_preserving_raw_url = substr_replace( + $source_preserving_raw_url, + $replacement['replacement'], + $replacement['start'], + $replacement['length'] + ); + } + + $source_preserving_url = self::parse( $source_preserving_raw_url, $new_base_url->toString() ); + $expected_url = self::parse( (string) $converted_url, $new_base_url->toString() ); + if ( + false !== $source_preserving_url && + false !== $expected_url && + $source_preserving_url->toString() === $expected_url->toString() + ) { + $converted_url->new_source_preserving_raw_url = $source_preserving_raw_url; + $converted_url->new_url = $source_preserving_url; + } + } } return $converted_url; } + /** + * Creates decoded-URL base replacements using slash-delimited path segments. + * + * Only spellings whose base-component boundaries can be located from literal delimiters are accepted. + * Ambiguous spellings return null. The caller reparses the edited string and accepts + * it only when it matches the normal semantic conversion. + * + * @return array