From 43bad058c20d0ace2a1555c70e330fb1808185e0 Mon Sep 17 00:00:00 2001 From: Mike Auteri Date: Fri, 11 Sep 2026 23:45:21 -0400 Subject: [PATCH 1/2] Leave fragment-only references alone when rewriting URLs A reference like `#section` points within the document that contains it, so there is no origin to migrate. Resolving it against the base URL made it look like a child of the source site, and rewriting it turned an in-page anchor into `/#section` and a bare `#` into `/`. Blocks whose saved markup carries such an href then fail validation after an import. Mirrors WordPress/wordpress-importer#263. Co-Authored-By: Claude Fable 5.1 --- .../DataLiberation/Tests/RewriteUrlsTest.php | 24 +++++++++++++++++++ components/DataLiberation/URL/functions.php | 15 +++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/components/DataLiberation/Tests/RewriteUrlsTest.php b/components/DataLiberation/Tests/RewriteUrlsTest.php index 17aca6d57..3b947432a 100644 --- a/components/DataLiberation/Tests/RewriteUrlsTest.php +++ b/components/DataLiberation/Tests/RewriteUrlsTest.php @@ -213,6 +213,30 @@ public static function provider_test_wp_rewrite_urls() { 'https://🚀-science.com/science', 'https://science.wordpress.org', ), + 'Fragment-only reference in an HTML attribute is left alone' => array( + 'Jump', + 'Jump', + 'https://legacy-blog.com', + 'https://modern-webstore.org', + ), + 'Bare hash in an HTML attribute is left alone' => array( + 'Placeholder', + 'Placeholder', + 'https://legacy-blog.com', + 'https://modern-webstore.org', + ), + 'Fragment-only reference in a block attribute is left alone' => array( + '', + '', + 'https://legacy-blog.com', + 'https://modern-webstore.org', + ), + 'Absolute URL carrying a fragment is still rewritten and keeps its fragment' => array( + 'Deep link', + 'Deep link', + 'https://legacy-blog.com', + 'https://modern-webstore.org', + ), ); } diff --git a/components/DataLiberation/URL/functions.php b/components/DataLiberation/URL/functions.php index fe931ce3d..daecc82d2 100644 --- a/components/DataLiberation/URL/functions.php +++ b/components/DataLiberation/URL/functions.php @@ -70,7 +70,20 @@ function wp_rewrite_urls( $options ) { while ( $p->next_url() ) { $token_type = $p->get_token_type(); $raw_url = $p->get_raw_url(); - $cache_key = $mapping_cache_key . "\0" . $token_type . "\0" . $raw_url; + + /* + * Leave fragment-only references alone. A URL like `#section` points + * within the document that contains it, so there is no origin to + * migrate. Resolving it against the base URL makes it look like a + * child of the site being imported from, and rewriting it then turns + * an in-page anchor into a link somewhere else entirely: + * `#section` becomes `/#section`, and a bare `#` becomes `/`. + */ + if ( is_string( $raw_url ) && 0 === strpos( $raw_url, '#' ) ) { + continue; + } + + $cache_key = $mapping_cache_key . "\0" . $token_type . "\0" . $raw_url; $cached = $rewrite_cache->get( $cache_key ); if ( null !== $cached ) { From bd2cca29adc9bc5526f57f34ab5402325677bf81 Mon Sep 17 00:00:00 2001 From: Mike Auteri Date: Tue, 6 Oct 2026 19:52:04 -0400 Subject: [PATCH 2/2] Move the fragment-only check into BlockMarkupUrlProcessor Adds BlockMarkupUrlProcessor::is_fragment_only_reference() and uses it in wp_rewrite_urls(). Like a URL parser, it ignores leading C0 control and space characters, so ` #section` is left alone too. Co-Authored-By: Claude Opus 5.5 --- .../class-blockmarkupurlprocessor.php | 20 +++++++++++++++++ .../Tests/BlockMarkupUrlProcessorTest.php | 22 +++++++++++++++++++ .../DataLiberation/Tests/RewriteUrlsTest.php | 6 +++++ components/DataLiberation/URL/functions.php | 12 +++++----- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php index 5c0452afe..79b35b672 100644 --- a/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php +++ b/components/DataLiberation/BlockMarkup/class-blockmarkupurlprocessor.php @@ -436,6 +436,26 @@ public function is_url_absolute() { return WPURL::can_parse( $this->get_raw_url() ); } + /** + * Returns true if the currently matched URL is a fragment-only reference, + * such as `#section` or a bare `#`. + * + * A fragment-only reference points within the document that contains it, + * so it has no origin of its own to rewrite. URL parsers strip leading C0 + * control and space characters before resolving a reference, so a value + * like ` #section` counts as one too. + * + * @return bool Whether the currently matched URL is a fragment-only reference. + */ + public function is_fragment_only_reference() { + $raw_url = $this->get_raw_url(); + if ( ! is_string( $raw_url ) ) { + return false; + } + + return 0 === strpos( ltrim( $raw_url, "\x00..\x20" ), '#' ); + } + public function get_inspected_attribute_name() { if ( '#tag' !== $this->get_token_type() ) { return false; diff --git a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php index 22193d9fe..b45a14825 100644 --- a/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php +++ b/components/DataLiberation/Tests/BlockMarkupUrlProcessorTest.php @@ -467,4 +467,26 @@ public function test_css_urls_with_regular_attributes() { $expected = ''; $this->assertEquals( $expected, $p->get_updated_html() ); } + + /** + * + * @dataProvider provider_test_is_fragment_only_reference + */ + public function test_is_fragment_only_reference( $markup, $expected ) { + $p = new BlockMarkupUrlProcessor( $markup, 'https://example.com' ); + $this->assertTrue( $p->next_url(), 'Failed to find the URL in the markup.' ); + $this->assertSame( $expected, $p->is_fragment_only_reference() ); + } + + public static function provider_test_is_fragment_only_reference() { + return array( + 'Named fragment' => array( 'Jump', true ), + 'Bare hash' => array( 'Top', true ), + 'Leading space' => array( 'Jump', true ), + 'Leading tab and newline' => array( "Jump", true ), + 'Fragment in a block attribute' => array( '', true ), + 'Absolute URL with a fragment' => array( 'Deep link', false ), + 'Relative path with a fragment' => array( 'Page', false ), + ); + } } diff --git a/components/DataLiberation/Tests/RewriteUrlsTest.php b/components/DataLiberation/Tests/RewriteUrlsTest.php index 3b947432a..d28f96a5f 100644 --- a/components/DataLiberation/Tests/RewriteUrlsTest.php +++ b/components/DataLiberation/Tests/RewriteUrlsTest.php @@ -225,6 +225,12 @@ public static function provider_test_wp_rewrite_urls() { 'https://legacy-blog.com', 'https://modern-webstore.org', ), + 'Fragment-only reference with leading whitespace is left alone' => array( + 'Jump', + 'Jump', + 'https://legacy-blog.com', + 'https://modern-webstore.org', + ), 'Fragment-only reference in a block attribute is left alone' => array( '', '', diff --git a/components/DataLiberation/URL/functions.php b/components/DataLiberation/URL/functions.php index daecc82d2..d3646ef13 100644 --- a/components/DataLiberation/URL/functions.php +++ b/components/DataLiberation/URL/functions.php @@ -72,14 +72,12 @@ function wp_rewrite_urls( $options ) { $raw_url = $p->get_raw_url(); /* - * Leave fragment-only references alone. A URL like `#section` points - * within the document that contains it, so there is no origin to - * migrate. Resolving it against the base URL makes it look like a - * child of the site being imported from, and rewriting it then turns - * an in-page anchor into a link somewhere else entirely: - * `#section` becomes `/#section`, and a bare `#` becomes `/`. + * Leave fragment-only references alone. Resolving one against the base + * URL makes it look like a child of the site being imported from, and + * rewriting it then turns an in-page anchor into a link somewhere else + * entirely: `#section` becomes `/#section`, and a bare `#` becomes `/`. */ - if ( is_string( $raw_url ) && 0 === strpos( $raw_url, '#' ) ) { + if ( $p->is_fragment_only_reference() ) { continue; }