Files
WP-Content-Sync/docs/superpowers/plans/2026-04-26-wordpress-content-sync-url-transformer.md
T
2026-04-26 12:51:55 -05:00

17 KiB

WordPress Content Sync URL Transformer Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the URL transformation layer that rewrites source-domain URLs to destination-domain URLs across content strings, HTML attributes, JSON strings, and serialized metadata.

Architecture: URL transformation is implemented as a focused service under src/Url/ with immutable mapping objects and a recursive metadata transformer. The transformer accepts explicit source/destination mappings, normalizes trailing slashes, preserves serialized data validity, and avoids WordPress database mutation so it can be tested with PHPUnit in isolation.

Tech Stack: PHP 7.4+, WordPress 5.6+, PHPUnit, PHPStan level 6+, PHPCS with project phpcs.xml.


File Structure

  • Create: src/Url/UrlMapping.php as an immutable source/destination mapping value object.
  • Create: src/Url/UrlMappingCollection.php as a validated list of mappings.
  • Create: src/Url/UrlTransformer.php for string, HTML, escaped URL, and protocol-relative replacement.
  • Create: src/Url/MetadataUrlTransformer.php for recursive arrays, JSON strings, and serialized PHP data.
  • Modify: src/Plugin.php to register UrlTransformer and MetadataUrlTransformer services.
  • Create: tests/Unit/Url/UrlMappingCollectionTest.php.
  • Create: tests/Unit/Url/UrlTransformerTest.php.
  • Create: tests/Unit/Url/MetadataUrlTransformerTest.php.

Task 1: URL Mapping Value Objects

Files:

  • Create: src/Url/UrlMapping.php

  • Create: src/Url/UrlMappingCollection.php

  • Create: tests/Unit/Url/UrlMappingCollectionTest.php

  • Step 1: Write failing mapping tests

Create tests/Unit/Url/UrlMappingCollectionTest.php:

<?php

namespace WPContentSync\Tests\Unit\Url;

use PHPUnit\Framework\TestCase;
use WPContentSync\Url\UrlMapping;
use WPContentSync\Url\UrlMappingCollection;

class UrlMappingCollectionTest extends TestCase {
	public function test_it_normalizes_mapping_urls_without_trailing_slashes(): void {
		$mapping = new UrlMapping( 'https://example.test/', 'https://staging.example.test/' );

		self::assertSame( 'https://example.test', $mapping->sourceUrl() );
		self::assertSame( 'https://staging.example.test', $mapping->destinationUrl() );
	}

	public function test_it_rejects_empty_mapping_urls(): void {
		$this->expectException( \InvalidArgumentException::class );
		$this->expectExceptionMessage( 'Source and destination URLs are required.' );

		new UrlMapping( '', 'https://staging.example.test' );
	}

	public function test_it_returns_mappings_in_order(): void {
		$first      = new UrlMapping( 'https://example.test', 'https://staging.example.test' );
		$second     = new UrlMapping( 'https://cdn.example.test', 'https://cdn.staging.example.test' );
		$collection = new UrlMappingCollection( array( $first, $second ) );

		self::assertSame( array( $first, $second ), $collection->all() );
	}

	public function test_it_rejects_non_mapping_items(): void {
		$this->expectException( \InvalidArgumentException::class );
		$this->expectExceptionMessage( 'URL mapping collections only accept UrlMapping instances.' );

		new UrlMappingCollection( array( new \stdClass() ) );
	}
}
  • Step 2: Run test to verify it fails

Run: composer test -- --filter UrlMappingCollectionTest

Expected: FAIL because WPContentSync\Url\UrlMapping does not exist.

  • Step 3: Implement UrlMapping

Create src/Url/UrlMapping.php:

<?php

namespace WPContentSync\Url;

final class UrlMapping {
	private string $source_url;
	private string $destination_url;

	public function __construct( string $source_url, string $destination_url ) {
		$source_url      = $this->normalizeUrl( $source_url );
		$destination_url = $this->normalizeUrl( $destination_url );

		if ( '' === $source_url || '' === $destination_url ) {
			throw new \InvalidArgumentException( 'Source and destination URLs are required.' );
		}

		$this->source_url      = $source_url;
		$this->destination_url = $destination_url;
	}

	public function sourceUrl(): string {
		return $this->source_url;
	}

	public function destinationUrl(): string {
		return $this->destination_url;
	}

	private function normalizeUrl( string $url ): string {
		return rtrim( trim( $url ), '/' );
	}
}
  • Step 4: Implement UrlMappingCollection

Create src/Url/UrlMappingCollection.php:

<?php

namespace WPContentSync\Url;

final class UrlMappingCollection {
	/**
	 * @var array<int, UrlMapping>
	 */
	private array $mappings;

	/**
	 * @param array<int, UrlMapping> $mappings URL mappings.
	 */
	public function __construct( array $mappings ) {
		foreach ( $mappings as $mapping ) {
			if ( ! $mapping instanceof UrlMapping ) {
				throw new \InvalidArgumentException( 'URL mapping collections only accept UrlMapping instances.' );
			}
		}

		$this->mappings = array_values( $mappings );
	}

	/**
	 * @return array<int, UrlMapping>
	 */
	public function all(): array {
		return $this->mappings;
	}
}
  • Step 5: Run test to verify it passes

Run: composer test -- --filter UrlMappingCollectionTest

Expected: PASS with 4 tests.

  • Step 6: Commit
git add src/Url/UrlMapping.php src/Url/UrlMappingCollection.php tests/Unit/Url/UrlMappingCollectionTest.php
git commit -m "feat: add url mapping value objects"

Task 2: Plain and HTML URL Transformation

Files:

  • Create: src/Url/UrlTransformer.php

  • Create: tests/Unit/Url/UrlTransformerTest.php

  • Step 1: Write failing transformer tests

Create tests/Unit/Url/UrlTransformerTest.php:

<?php

namespace WPContentSync\Tests\Unit\Url;

use PHPUnit\Framework\TestCase;
use WPContentSync\Url\UrlMapping;
use WPContentSync\Url\UrlMappingCollection;
use WPContentSync\Url\UrlTransformer;

class UrlTransformerTest extends TestCase {
	public function test_it_rewrites_plain_urls(): void {
		$transformer = new UrlTransformer();
		$mappings    = new UrlMappingCollection(
			array(
				new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
			)
		);

		self::assertSame(
			'Visit https://staging.example.test/about for details.',
			$transformer->transformString( 'Visit https://example.test/about for details.', $mappings )
		);
	}

	public function test_it_rewrites_html_attribute_urls(): void {
		$transformer = new UrlTransformer();
		$mappings    = new UrlMappingCollection(
			array(
				new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
			)
		);

		self::assertSame(
			'<a href="https://staging.example.test/about"><img src="https://staging.example.test/image.jpg"></a>',
			$transformer->transformString(
				'<a href="https://example.test/about"><img src="https://example.test/image.jpg"></a>',
				$mappings
			)
		);
	}

	public function test_it_rewrites_escaped_urls(): void {
		$transformer = new UrlTransformer();
		$mappings    = new UrlMappingCollection(
			array(
				new UrlMapping( 'https://example.test/path?a=1&b=2', 'https://staging.example.test/path?a=1&b=2' ),
			)
		);

		self::assertSame(
			'https://staging.example.test/path?a=1&amp;b=2',
			$transformer->transformString( 'https://example.test/path?a=1&amp;b=2', $mappings )
		);
	}

	public function test_it_rewrites_protocol_relative_urls(): void {
		$transformer = new UrlTransformer();
		$mappings    = new UrlMappingCollection(
			array(
				new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
			)
		);

		self::assertSame(
			'//staging.example.test/uploads/file.pdf',
			$transformer->transformString( '//example.test/uploads/file.pdf', $mappings )
		);
	}

	public function test_it_supports_multiple_mappings(): void {
		$transformer = new UrlTransformer();
		$mappings    = new UrlMappingCollection(
			array(
				new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
				new UrlMapping( 'https://cdn.example.test', 'https://cdn.staging.example.test' ),
			)
		);

		self::assertSame(
			'https://staging.example.test https://cdn.staging.example.test/image.jpg',
			$transformer->transformString( 'https://example.test https://cdn.example.test/image.jpg', $mappings )
		);
	}
}
  • Step 2: Run test to verify it fails

Run: composer test -- --filter UrlTransformerTest

Expected: FAIL because WPContentSync\Url\UrlTransformer does not exist.

  • Step 3: Implement UrlTransformer

Create src/Url/UrlTransformer.php:

<?php

namespace WPContentSync\Url;

final class UrlTransformer {
	public function transformString( string $value, UrlMappingCollection $mappings ): string {
		$transformed = $value;

		foreach ( $mappings->all() as $mapping ) {
			$transformed = $this->replaceMapping( $transformed, $mapping );
		}

		return $transformed;
	}

	private function replaceMapping( string $value, UrlMapping $mapping ): string {
		$source      = $mapping->sourceUrl();
		$destination = $mapping->destinationUrl();

		$replacements = array(
			$source                                 => $destination,
			str_replace( '&', '&amp;', $source )    => str_replace( '&', '&amp;', $destination ),
			$this->toProtocolRelative( $source )   => $this->toProtocolRelative( $destination ),
		);

		return strtr( $value, $replacements );
	}

	private function toProtocolRelative( string $url ): string {
		return preg_replace( '#^https?:#', '', $url ) ?? $url;
	}
}
  • Step 4: Run test to verify it passes

Run: composer test -- --filter UrlTransformerTest

Expected: PASS with 5 tests.

  • Step 5: Commit
git add src/Url/UrlTransformer.php tests/Unit/Url/UrlTransformerTest.php
git commit -m "feat: add url string transformer"

Task 3: Metadata URL Transformation

Files:

  • Create: src/Url/MetadataUrlTransformer.php

  • Create: tests/Unit/Url/MetadataUrlTransformerTest.php

  • Step 1: Write failing metadata tests

Create tests/Unit/Url/MetadataUrlTransformerTest.php:

<?php

namespace WPContentSync\Tests\Unit\Url;

use PHPUnit\Framework\TestCase;
use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlMapping;
use WPContentSync\Url\UrlMappingCollection;
use WPContentSync\Url\UrlTransformer;

class MetadataUrlTransformerTest extends TestCase {
	private function mappings(): UrlMappingCollection {
		return new UrlMappingCollection(
			array(
				new UrlMapping( 'https://example.test', 'https://staging.example.test' ),
			)
		);
	}

	public function test_it_recursively_transforms_array_metadata(): void {
		$transformer = new MetadataUrlTransformer( new UrlTransformer() );

		self::assertSame(
			array(
				'hero' => array(
					'url' => 'https://staging.example.test/uploads/hero.jpg',
				),
			),
			$transformer->transformValue(
				array(
					'hero' => array(
						'url' => 'https://example.test/uploads/hero.jpg',
					),
				),
				$this->mappings()
			)
		);
	}

	public function test_it_transforms_json_strings(): void {
		$transformer = new MetadataUrlTransformer( new UrlTransformer() );
		$result      = $transformer->transformValue(
			'{"url":"https:\/\/example.test\/uploads\/hero.jpg"}',
			$this->mappings()
		);

		self::assertSame( '{"url":"https:\/\/staging.example.test\/uploads\/hero.jpg"}', $result );
	}

	public function test_it_preserves_serialized_data_validity(): void {
		$transformer = new MetadataUrlTransformer( new UrlTransformer() );
		$serialized  = serialize(
			array(
				'url' => 'https://example.test/uploads/hero.jpg',
			)
		);

		$result = $transformer->transformValue( $serialized, $this->mappings() );

		self::assertSame(
			array(
				'url' => 'https://staging.example.test/uploads/hero.jpg',
			),
			unserialize( $result )
		);
	}

	public function test_it_transforms_plain_string_metadata(): void {
		$transformer = new MetadataUrlTransformer( new UrlTransformer() );

		self::assertSame(
			'https://staging.example.test/contact',
			$transformer->transformValue( 'https://example.test/contact', $this->mappings() )
		);
	}
}
  • Step 2: Run test to verify it fails

Run: composer test -- --filter MetadataUrlTransformerTest

Expected: FAIL because WPContentSync\Url\MetadataUrlTransformer does not exist.

  • Step 3: Implement MetadataUrlTransformer

Create src/Url/MetadataUrlTransformer.php:

<?php

namespace WPContentSync\Url;

final class MetadataUrlTransformer {
	private UrlTransformer $url_transformer;

	public function __construct( UrlTransformer $url_transformer ) {
		$this->url_transformer = $url_transformer;
	}

	/**
	 * @param mixed $value Metadata value.
	 * @return mixed
	 */
	public function transformValue( $value, UrlMappingCollection $mappings ) {
		if ( is_array( $value ) ) {
			return $this->transformArray( $value, $mappings );
		}

		if ( ! is_string( $value ) ) {
			return $value;
		}

		if ( $this->isSerialized( $value ) ) {
			$unserialized = unserialize( $value );
			return serialize( $this->transformValue( $unserialized, $mappings ) );
		}

		if ( $this->isJsonObjectOrArray( $value ) ) {
			$decoded = json_decode( $value, true );
			if ( is_array( $decoded ) ) {
				return wp_json_encode( $this->transformArray( $decoded, $mappings ) );
			}
		}

		return $this->url_transformer->transformString( $value, $mappings );
	}

	/**
	 * @param array<mixed> $value Metadata array.
	 * @return array<mixed>
	 */
	private function transformArray( array $value, UrlMappingCollection $mappings ): array {
		$transformed = array();

		foreach ( $value as $key => $item ) {
			$transformed[ $key ] = $this->transformValue( $item, $mappings );
		}

		return $transformed;
	}

	private function isSerialized( string $value ): bool {
		return false !== @unserialize( $value );
	}

	private function isJsonObjectOrArray( string $value ): bool {
		$trimmed = trim( $value );

		return '' !== $trimmed && in_array( $trimmed[0], array( '{', '[' ), true );
	}
}
  • Step 4: Add wp_json_encode test stub

Modify tests/bootstrap.php by appending:

if ( ! function_exists( 'wp_json_encode' ) ) {
	function wp_json_encode( $value ) {
		return json_encode( $value );
	}
}
  • Step 5: Run test to verify it passes

Run: composer test -- --filter MetadataUrlTransformerTest

Expected: PASS with 4 tests.

  • Step 6: Commit
git add src/Url/MetadataUrlTransformer.php tests/Unit/Url/MetadataUrlTransformerTest.php tests/bootstrap.php
git commit -m "feat: add metadata url transformer"

Task 4: Service Wiring

Files:

  • Modify: src/Plugin.php

  • Step 1: Wire URL services into the plugin container

Modify src/Plugin.php imports:

use WPContentSync\Url\MetadataUrlTransformer;
use WPContentSync\Url\UrlTransformer;

Inside Plugin::create(), before the AdminPage::class factory, add:

$container->factory(
	UrlTransformer::class,
	static function (): UrlTransformer {
		return new UrlTransformer();
	}
);

$container->factory(
	MetadataUrlTransformer::class,
	static function () use ( $container ): MetadataUrlTransformer {
		return new MetadataUrlTransformer(
			$container->get( UrlTransformer::class )
		);
	}
);
  • Step 2: Run static analysis

Run: composer stan

Expected: PASS with no PHPStan errors.

  • Step 3: Run tests

Run: composer test

Expected: PASS with all tests.

  • Step 4: Commit
git add src/Plugin.php
git commit -m "feat: register url transformation services"

Task 5: Full URL Transformer Verification

Files:

  • Verify all files created or modified in Tasks 1-4.

  • Step 1: Run Composer validation

Run: composer validate --strict

Expected: PASS.

  • Step 2: Run PHPCS

Run: composer lint

Expected: PASS.

  • Step 3: Run PHPStan

Run: composer stan

Expected: PASS.

  • Step 4: Run PHPUnit

Run: composer test

Expected: PASS with the existing foundation tests plus URL transformer tests.

  • Step 5: Manual smoke check in a PHP shell

Run:

php -r "require 'vendor/autoload.php'; $m=new WPContentSync\Url\UrlMappingCollection([new WPContentSync\Url\UrlMapping('https://example.test','https://staging.example.test')]); $t=new WPContentSync\Url\UrlTransformer(); echo $t->transformString('https://example.test/about',$m), PHP_EOL;"

Expected output:

https://staging.example.test/about
  • Step 6: Commit any verification fixes
git status --short
git add src tests
git commit -m "chore: verify url transformer"

Self-Review

Spec coverage in this URL transformer plan:

  • Plain text URL replacement is covered in Task 2.
  • HTML attribute URL replacement is covered in Task 2 because the transformer rewrites URLs inside HTML strings without parsing/mutating markup structure.
  • Escaped URLs are covered in Task 2 through &amp; replacement.
  • Protocol-relative URLs are covered in Task 2.
  • Multiple source/destination mappings are covered in Tasks 1 and 2.
  • JSON strings, serialized PHP arrays, and nested metadata arrays are covered in Task 3.
  • GUID and permalink transformation rules are represented by the same string transformer used for any URL-bearing field; later content-handler phases will decide which post fields should be passed through it.

Deferred by design:

  • Admin UI for editing URL mappings remains in the later admin workflow phase.
  • Database mutation and post/meta import logic remain in content-handler phases.
  • Large-scale search/replace progress reporting remains in sync engine phases.