# 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 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 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 */ private array $mappings; /** * @param array $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 */ 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** ```bash 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 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( '', $transformer->transformString( '', $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&b=2', $transformer->transformString( 'https://example.test/path?a=1&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 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( '&', '&', $source ) => str_replace( '&', '&', $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** ```bash 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 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 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 $value Metadata array. * @return array */ 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: ```php 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** ```bash 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: ```php use WPContentSync\Url\MetadataUrlTransformer; use WPContentSync\Url\UrlTransformer; ``` Inside `Plugin::create()`, before the `AdminPage::class` factory, add: ```php $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** ```bash 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: ```bash 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: ```text https://staging.example.test/about ``` - [ ] **Step 6: Commit any verification fixes** ```bash 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 `&` 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.