Skip to content

Do not rewrite fragment-only URLs on import - #263

Open
mauteri wants to merge 1 commit into
WordPress:masterfrom
mauteri:fix/do-not-rewrite-fragment-only-urls
Open

Do not rewrite fragment-only URLs on import#263
mauteri wants to merge 1 commit into
WordPress:masterfrom
mauteri:fix/do-not-rewrite-fragment-only-urls

Conversation

@mauteri

@mauteri mauteri commented Jul 24, 2026

Copy link
Copy Markdown

The problem

A reference like #section points within the document that contains it. It has no host and no path, so there is no origin to migrate.

wp_rewrite_urls() resolves every URL against the base URL before consulting the mapping. For a fragment-only reference that resolution produces https://old-site.com/current-page#section, which then matches is_child_url_of() and gets rewritten against the new home:

in out
<a href="#section"> <a href="/#section">
<a href="#add-to-calendar"> <a href="/#add-to-calendar">
<a href="#"> <a href="/">

The last one is the worst: the fragment is dropped entirely, so an in-page anchor becomes a link to the site root.

Why it matters

Beyond the broken links, this breaks block validation. Any block whose saved markup contains a fragment-only href (accordions, tab and anchor navigation, "jump to" links, and blocks that use # as a JavaScript-handled placeholder) no longer matches its save() output after an import, and the editor shows "This block contains unexpected or invalid content."

The workaround people land on is disabling URL rewriting altogether:

add_filter( 'wp_import_options', function ( $options ) {
    $options['rewrite_urls'] = false;
    return $options;
} );

That is a heavy price for one class of URL, and it gives up the migration for every genuine link in the content.

The fix

Skip fragment-only references before the mapping is consulted.

The guard deliberately reads get_raw_url() rather than the parsed URL, because parsing is what destroys the distinction: once #section has been resolved against the base it is indistinguishable from an ordinary same-site link, which is exactly why the current code rewrites it.

Scoped to references that are only a fragment. A URL carrying a fragment alongside a host or path still rewrites, and still keeps its fragment:

https://old-site.com/page#section  ->  https://new-site.com/page#section

Tests

phpunit/tests/rewrite-urls.php covers fragment-only hrefs in both markup and block attributes, plus the two cases that must keep working (absolute URL, absolute URL carrying a fragment).

Verified they fail before the change and pass after:

without the fix                       with the fix
[FAIL] named fragment                 [PASS] named fragment
[FAIL] bare hash                      [PASS] bare hash
[FAIL] hyphenated fragment            [PASS] hyphenated fragment
[FAIL] fragment in a block attribute  [PASS] fragment in a block attribute
[PASS] absolute + fragment            [PASS] absolute + fragment
[PASS] absolute                       [PASS] absolute

The two controls passing in both columns is the part that matters: the guard does not over-correct.

Notes

  • This code is also in WordPress/php-toolkit (components/DataLiberation/URL/functions.php), vendored here under src/php-toolkit/. Happy to open the matching PR there so the two do not diverge, just say the word.
  • Related, not fixed here: query-only references have the same shape. <a href="?filter=all"> currently becomes <a href="/?filter=all">, which likewise moves the link from the current page to the site root. I left it alone to keep this change to the reported behavior, but it may be worth the same treatment.
  • phpcs --standard=phpcs.xml.dist is clean on both files. I could not run the PHPUnit suite locally (the pinned PHPUnit predates my PHP version, as CONTRIBUTING.md notes), so those run on CI.

A reference like `#section` points within the document that contains it.
It has no host and no path, so there is no origin to migrate.

wp_rewrite_urls() resolved it against the base URL first, which made it
look like a child of the site being imported from, and it was then
rewritten against the new home:

  #section          becomes  /#section
  #add-to-calendar  becomes  /#add-to-calendar
  #                 becomes  /            (fragment dropped entirely)

An in-page anchor therefore turns into a link to a different page, and
blocks whose saved markup contains such an href fail validation after an
import because the serialized attribute no longer matches what the block
expects. Sites have been working around this by disabling URL rewriting
altogether via the wp_import_options filter, which is a heavy price for
one class of URL.

Skips those references before the mapping is consulted. The guard reads
the raw URL rather than the parsed one, because parsing is what loses the
distinction: by the time a fragment has been resolved against the base it
is indistinguishable from a same-site link.

Scoped deliberately to references that are *only* a fragment. A URL that
carries a fragment alongside a host or path is still rewritten, and still
keeps its fragment.

Adds regression tests covering fragment-only hrefs in markup and in block
attributes, plus the two cases that must keep working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mauteri

mauteri commented Jul 24, 2026

Copy link
Copy Markdown
Author

Flagging the red checks so they don't get misread as fallout from this change: they are pre-existing and unrelated to the diff.

Every failing job dies in composer install, before a single test runs:

Root composer.json requires phpunit/phpunit ^5.7.21 || ^6.5 || ^7.5 ... but these were not loaded, because they are affected by security advisories (PKSA-z3gr-8qht-p93v)

Composer blocks advisory-affected packages by default now, and the pinned PHPUnit range is entirely within that advisory, so dependency resolution fails outright.

master has the same failures on its own scheduled runs, going back over a month:

run date result
29709876418 2026-07-20 failure
29215890001 2026-07-13 failure
28760885388 2026-07-06 failure
28342218932 2026-06-29 failure
27923492015 2026-06-22 failure

The failing job names line up too (PHP 7.2 - WP 5.2, PHP 8.0 - WP 5.6, PHP 8.2 - WP 6.2, and so on).

Meanwhile everything that did manage to install and run on this PR passed: all three E2E shards, plus WP latest, WP trunk, PHP 8.4 - WP 6.7, and the PHP 8.0/8.1/8.3 - WP latest jobs.

Happy to open a separate PR unblocking CI if that would help. The options are roughly: allow the advisory through config.policy.advisories, or move the dev dependency onto a PHPUnit version outside the advisory range via yoast/phpunit-polyfills. That felt like it belonged in its own change rather than bundled in here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant