1262 lines
32 KiB
Markdown
1262 lines
32 KiB
Markdown
# WordPress Content Sync Foundation 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 loadable WordPress plugin foundation for WP Content Sync with tooling, bootstrap, settings, logging, and an admin dashboard shell.
|
|
|
|
**Architecture:** The foundation uses a small bootstrap file at the plugin root and focused PHP classes under `src/`. A service container wires settings, logging, activation hooks, and admin screens while keeping WordPress calls at clear boundaries so unit tests can cover the domain behavior without a full WordPress runtime.
|
|
|
|
**Tech Stack:** PHP 7.4+, WordPress 5.6+, Composer, PHPUnit, PHPStan level 6+, PHP_CodeSniffer with the project `phpcs.xml`, WordPress Coding Standards.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Create: `wp-content-sync.php` as the WordPress plugin entrypoint.
|
|
- Create: `composer.json` for autoloading, dev dependencies, and scripts.
|
|
- Create: `phpcs.xml` for WordPress-oriented coding standards.
|
|
- Create: `phpstan.neon` for level 6 static analysis.
|
|
- Create: `phpunit.xml.dist` for unit test configuration.
|
|
- Create: `src/Plugin.php` as the lifecycle coordinator.
|
|
- Create: `src/Container.php` as the lightweight service registry.
|
|
- Create: `src/Activator.php` to install default options on activation.
|
|
- Create: `src/Deactivator.php` to perform safe deactivation cleanup.
|
|
- Create: `src/Settings/Settings.php` as a typed settings value object.
|
|
- Create: `src/Settings/SettingsRepository.php` as the WordPress option boundary.
|
|
- Create: `src/Logging/LoggerInterface.php` as the logging boundary.
|
|
- Create: `src/Logging/OptionLogger.php` as the initial bounded option-backed logger.
|
|
- Create: `src/Admin/AdminPage.php` as the admin dashboard controller.
|
|
- Create: `templates/admin/dashboard.php` as the escaped dashboard view.
|
|
- Create: `tests/bootstrap.php` for PHPUnit bootstrap and WordPress function stubs.
|
|
- Create: `tests/Unit/SettingsTest.php` for settings defaults and sanitization behavior.
|
|
- Create: `tests/Unit/ContainerTest.php` for service registration behavior.
|
|
- Create: `tests/Unit/OptionLoggerTest.php` for log redaction and retention behavior.
|
|
|
|
## Task 1: Add Composer and Quality Tooling
|
|
|
|
**Files:**
|
|
- Create: `composer.json`
|
|
- Create: `phpcs.xml`
|
|
- Create: `phpstan.neon`
|
|
- Create: `phpunit.xml.dist`
|
|
|
|
- [ ] **Step 1: Create Composer metadata and scripts**
|
|
|
|
Create `composer.json`:
|
|
|
|
```json
|
|
{
|
|
"name": "ksolo/wp-content-sync",
|
|
"description": "Bidirectional WordPress content synchronization plugin.",
|
|
"type": "wordpress-plugin",
|
|
"license": "GPL-2.0-or-later",
|
|
"require": {
|
|
"php": ">=7.4"
|
|
},
|
|
"require-dev": {
|
|
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
|
"phpstan/phpstan": "^1.10",
|
|
"phpunit/phpunit": "^9.6",
|
|
"squizlabs/php_codesniffer": "^3.9",
|
|
"wp-coding-standards/wpcs": "^3.0"
|
|
},
|
|
"autoload": {
|
|
"psr-4": {
|
|
"WPContentSync\\": "src/"
|
|
}
|
|
},
|
|
"autoload-dev": {
|
|
"psr-4": {
|
|
"WPContentSync\\Tests\\": "tests/"
|
|
}
|
|
},
|
|
"scripts": {
|
|
"validate": "composer validate --strict",
|
|
"lint": "phpcs",
|
|
"lint:fix": "phpcbf",
|
|
"stan": "phpstan analyse",
|
|
"test": "phpunit"
|
|
},
|
|
"config": {
|
|
"allow-plugins": {
|
|
"dealerdirect/phpcodesniffer-composer-installer": true
|
|
},
|
|
"sort-packages": true
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create PHPCS rules**
|
|
|
|
Create `phpcs.xml`:
|
|
|
|
```xml
|
|
<?xml version="1.0"?>
|
|
<ruleset name="WP Content Sync">
|
|
<description>Custom coding standards for WP Content Sync.</description>
|
|
<arg name="basepath" value="."/>
|
|
<arg name="extensions" value="php"/>
|
|
<arg name="parallel" value="8"/>
|
|
<file>wp-content-sync.php</file>
|
|
<file>src</file>
|
|
<file>tests</file>
|
|
<rule ref="WordPress">
|
|
<exclude name="WordPress.Files.FileName.InvalidClassFileName"/>
|
|
<exclude name="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedNamespaceFound"/>
|
|
</rule>
|
|
<rule ref="WordPress-Docs"/>
|
|
<rule ref="WordPress-Extra"/>
|
|
<config name="minimum_supported_wp_version" value="5.6"/>
|
|
<config name="testVersion" value="7.4-"/>
|
|
<exclude-pattern>vendor/*</exclude-pattern>
|
|
</ruleset>
|
|
```
|
|
|
|
- [ ] **Step 3: Create PHPStan configuration**
|
|
|
|
Create `phpstan.neon`:
|
|
|
|
```neon
|
|
parameters:
|
|
level: 6
|
|
paths:
|
|
- src
|
|
- wp-content-sync.php
|
|
scanFiles:
|
|
- tests/bootstrap.php
|
|
bootstrapFiles:
|
|
- tests/bootstrap.php
|
|
```
|
|
|
|
- [ ] **Step 4: Create PHPUnit configuration**
|
|
|
|
Create `phpunit.xml.dist`:
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<phpunit bootstrap="tests/bootstrap.php" colors="true" beStrictAboutTestsThatDoNotTestAnything="true">
|
|
<testsuites>
|
|
<testsuite name="Unit">
|
|
<directory>tests/Unit</directory>
|
|
</testsuite>
|
|
</testsuites>
|
|
</phpunit>
|
|
```
|
|
|
|
- [ ] **Step 5: Install dependencies**
|
|
|
|
Run: `composer install`
|
|
|
|
Expected: Composer installs dev dependencies and generates `vendor/autoload.php`.
|
|
|
|
- [ ] **Step 6: Validate Composer metadata**
|
|
|
|
Run: `composer validate --strict`
|
|
|
|
Expected: PASS with no schema or lock warnings after `composer install`.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add composer.json composer.lock phpcs.xml phpstan.neon phpunit.xml.dist
|
|
git commit -m "chore: add php quality tooling"
|
|
```
|
|
|
|
## Task 2: Add PHPUnit Bootstrap and Core Test Scaffolding
|
|
|
|
**Files:**
|
|
- Create: `tests/bootstrap.php`
|
|
- Create: `tests/Unit/ContainerTest.php`
|
|
- Create: `src/Container.php`
|
|
|
|
- [ ] **Step 1: Write the failing container tests**
|
|
|
|
Create `tests/Unit/ContainerTest.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync\Tests\Unit;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use WPContentSync\Container;
|
|
|
|
class ContainerTest extends TestCase {
|
|
public function test_it_returns_registered_service(): void {
|
|
$container = new Container();
|
|
$service = new \stdClass();
|
|
|
|
$container->set( 'example', $service );
|
|
|
|
self::assertSame( $service, $container->get( 'example' ) );
|
|
}
|
|
|
|
public function test_it_reuses_factory_result(): void {
|
|
$container = new Container();
|
|
$calls = 0;
|
|
|
|
$container->factory(
|
|
'example',
|
|
static function () use ( &$calls ): \stdClass {
|
|
++$calls;
|
|
return new \stdClass();
|
|
}
|
|
);
|
|
|
|
$first = $container->get( 'example' );
|
|
$second = $container->get( 'example' );
|
|
|
|
self::assertSame( $first, $second );
|
|
self::assertSame( 1, $calls );
|
|
}
|
|
|
|
public function test_it_throws_for_unknown_service(): void {
|
|
$container = new Container();
|
|
|
|
$this->expectException( \InvalidArgumentException::class );
|
|
$this->expectExceptionMessage( 'Service "missing" is not registered.' );
|
|
|
|
$container->get( 'missing' );
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add test bootstrap with WordPress stubs**
|
|
|
|
Create `tests/bootstrap.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
require_once dirname( __DIR__ ) . '/vendor/autoload.php';
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
|
|
}
|
|
|
|
if ( ! function_exists( 'sanitize_text_field' ) ) {
|
|
function sanitize_text_field( $value ) {
|
|
return trim( preg_replace( '/[\r\n\t]+/', ' ', strip_tags( (string) $value ) ) );
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'esc_html' ) ) {
|
|
function esc_html( $value ) {
|
|
return htmlspecialchars( (string) $value, ENT_QUOTES, 'UTF-8' );
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'esc_attr' ) ) {
|
|
function esc_attr( $value ) {
|
|
return htmlspecialchars( (string) $value, ENT_QUOTES, 'UTF-8' );
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'esc_url' ) ) {
|
|
function esc_url( $value ) {
|
|
return filter_var( (string) $value, FILTER_SANITIZE_URL );
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run test to verify it fails**
|
|
|
|
Run: `composer test -- --filter ContainerTest`
|
|
|
|
Expected: FAIL because class `WPContentSync\Container` does not exist.
|
|
|
|
- [ ] **Step 4: Implement the container**
|
|
|
|
Create `src/Container.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync;
|
|
|
|
final class Container {
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $services = array();
|
|
|
|
/**
|
|
* @var array<string, callable(): mixed>
|
|
*/
|
|
private array $factories = array();
|
|
|
|
public function set( string $id, $service ): void {
|
|
$this->services[ $id ] = $service;
|
|
}
|
|
|
|
/**
|
|
* @param callable(): mixed $factory
|
|
*/
|
|
public function factory( string $id, callable $factory ): void {
|
|
$this->factories[ $id ] = $factory;
|
|
}
|
|
|
|
public function get( string $id ) {
|
|
if ( array_key_exists( $id, $this->services ) ) {
|
|
return $this->services[ $id ];
|
|
}
|
|
|
|
if ( array_key_exists( $id, $this->factories ) ) {
|
|
$this->services[ $id ] = $this->factories[ $id ]();
|
|
return $this->services[ $id ];
|
|
}
|
|
|
|
throw new \InvalidArgumentException( sprintf( 'Service "%s" is not registered.', $id ) );
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `composer test -- --filter ContainerTest`
|
|
|
|
Expected: PASS with 3 tests.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/Container.php tests/bootstrap.php tests/Unit/ContainerTest.php
|
|
git commit -m "test: add service container coverage"
|
|
```
|
|
|
|
## Task 3: Add Settings Value Object and Repository
|
|
|
|
**Files:**
|
|
- Create: `src/Settings/Settings.php`
|
|
- Create: `src/Settings/SettingsRepository.php`
|
|
- Create: `tests/Unit/SettingsTest.php`
|
|
|
|
- [ ] **Step 1: Write failing settings tests**
|
|
|
|
Create `tests/Unit/SettingsTest.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync\Tests\Unit;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use WPContentSync\Settings\Settings;
|
|
|
|
class SettingsTest extends TestCase {
|
|
public function test_it_provides_secure_defaults(): void {
|
|
$settings = Settings::fromArray( array() );
|
|
|
|
self::assertSame( array(), $settings->syncPairs() );
|
|
self::assertSame( 'warning', $settings->loggingLevel() );
|
|
self::assertTrue( $settings->automaticUrlReplacementEnabled() );
|
|
self::assertSame( 'last_write_wins', $settings->conflictStrategy() );
|
|
}
|
|
|
|
public function test_it_sanitizes_scalar_settings(): void {
|
|
$settings = Settings::fromArray(
|
|
array(
|
|
'logging_level' => '<b>debug</b>',
|
|
'conflict_strategy' => "manual_review\n",
|
|
'automatic_url_replacement' => false,
|
|
)
|
|
);
|
|
|
|
self::assertSame( 'debug', $settings->loggingLevel() );
|
|
self::assertSame( 'manual_review', $settings->conflictStrategy() );
|
|
self::assertFalse( $settings->automaticUrlReplacementEnabled() );
|
|
}
|
|
|
|
public function test_it_rejects_unknown_logging_level(): void {
|
|
$settings = Settings::fromArray(
|
|
array(
|
|
'logging_level' => 'verbose',
|
|
)
|
|
);
|
|
|
|
self::assertSame( 'warning', $settings->loggingLevel() );
|
|
}
|
|
|
|
public function test_it_serializes_to_array(): void {
|
|
$settings = Settings::fromArray(
|
|
array(
|
|
'sync_pairs' => array(
|
|
array(
|
|
'name' => 'Staging',
|
|
'source_url' => 'https://example.test',
|
|
'destination_url' => 'https://staging.example.test',
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
self::assertSame(
|
|
array(
|
|
'sync_pairs' => array(
|
|
array(
|
|
'name' => 'Staging',
|
|
'source_url' => 'https://example.test',
|
|
'destination_url' => 'https://staging.example.test',
|
|
),
|
|
),
|
|
'logging_level' => 'warning',
|
|
'automatic_url_replacement' => true,
|
|
'conflict_strategy' => 'last_write_wins',
|
|
),
|
|
$settings->toArray()
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `composer test -- --filter SettingsTest`
|
|
|
|
Expected: FAIL because class `WPContentSync\Settings\Settings` does not exist.
|
|
|
|
- [ ] **Step 3: Implement settings value object**
|
|
|
|
Create `src/Settings/Settings.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync\Settings;
|
|
|
|
final class Settings {
|
|
private const LOGGING_LEVELS = array( 'error', 'warning', 'info', 'debug' );
|
|
private const CONFLICT_STRATEGIES = array( 'last_write_wins', 'manual_review' );
|
|
|
|
/**
|
|
* @var array<int, array{name: string, source_url: string, destination_url: string}>
|
|
*/
|
|
private array $sync_pairs;
|
|
|
|
private string $logging_level;
|
|
private bool $automatic_url_replacement;
|
|
private string $conflict_strategy;
|
|
|
|
/**
|
|
* @param array<int, array{name: string, source_url: string, destination_url: string}> $sync_pairs
|
|
*/
|
|
private function __construct(
|
|
array $sync_pairs,
|
|
string $logging_level,
|
|
bool $automatic_url_replacement,
|
|
string $conflict_strategy
|
|
) {
|
|
$this->sync_pairs = $sync_pairs;
|
|
$this->logging_level = $logging_level;
|
|
$this->automatic_url_replacement = $automatic_url_replacement;
|
|
$this->conflict_strategy = $conflict_strategy;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public static function fromArray( array $data ): self {
|
|
$logging_level = self::sanitizeChoice(
|
|
$data['logging_level'] ?? 'warning',
|
|
self::LOGGING_LEVELS,
|
|
'warning'
|
|
);
|
|
|
|
$conflict_strategy = self::sanitizeChoice(
|
|
$data['conflict_strategy'] ?? 'last_write_wins',
|
|
self::CONFLICT_STRATEGIES,
|
|
'last_write_wins'
|
|
);
|
|
|
|
$automatic_url_replacement = array_key_exists( 'automatic_url_replacement', $data )
|
|
? (bool) $data['automatic_url_replacement']
|
|
: true;
|
|
|
|
return new self(
|
|
self::sanitizeSyncPairs( $data['sync_pairs'] ?? array() ),
|
|
$logging_level,
|
|
$automatic_url_replacement,
|
|
$conflict_strategy
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{name: string, source_url: string, destination_url: string}>
|
|
*/
|
|
public function syncPairs(): array {
|
|
return $this->sync_pairs;
|
|
}
|
|
|
|
public function loggingLevel(): string {
|
|
return $this->logging_level;
|
|
}
|
|
|
|
public function automaticUrlReplacementEnabled(): bool {
|
|
return $this->automatic_url_replacement;
|
|
}
|
|
|
|
public function conflictStrategy(): string {
|
|
return $this->conflict_strategy;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function toArray(): array {
|
|
return array(
|
|
'sync_pairs' => $this->sync_pairs,
|
|
'logging_level' => $this->logging_level,
|
|
'automatic_url_replacement' => $this->automatic_url_replacement,
|
|
'conflict_strategy' => $this->conflict_strategy,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
* @param array<int, string> $allowed
|
|
*/
|
|
private static function sanitizeChoice( $value, array $allowed, string $fallback ): string {
|
|
$sanitized = sanitize_text_field( (string) $value );
|
|
|
|
return in_array( $sanitized, $allowed, true ) ? $sanitized : $fallback;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $pairs
|
|
* @return array<int, array{name: string, source_url: string, destination_url: string}>
|
|
*/
|
|
private static function sanitizeSyncPairs( $pairs ): array {
|
|
if ( ! is_array( $pairs ) ) {
|
|
return array();
|
|
}
|
|
|
|
$sanitized = array();
|
|
|
|
foreach ( $pairs as $pair ) {
|
|
if ( ! is_array( $pair ) ) {
|
|
continue;
|
|
}
|
|
|
|
$name = sanitize_text_field( (string) ( $pair['name'] ?? '' ) );
|
|
$source_url = esc_url( (string) ( $pair['source_url'] ?? '' ) );
|
|
$destination_url = esc_url( (string) ( $pair['destination_url'] ?? '' ) );
|
|
|
|
if ( '' === $name || '' === $source_url || '' === $destination_url ) {
|
|
continue;
|
|
}
|
|
|
|
$sanitized[] = array(
|
|
'name' => $name,
|
|
'source_url' => $source_url,
|
|
'destination_url' => $destination_url,
|
|
);
|
|
}
|
|
|
|
return $sanitized;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement settings repository**
|
|
|
|
Create `src/Settings/SettingsRepository.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync\Settings;
|
|
|
|
final class SettingsRepository {
|
|
public const OPTION_NAME = 'wpcs_settings';
|
|
|
|
public function get(): Settings {
|
|
$value = get_option( self::OPTION_NAME, array() );
|
|
|
|
return Settings::fromArray( is_array( $value ) ? $value : array() );
|
|
}
|
|
|
|
public function save( Settings $settings ): void {
|
|
update_option( self::OPTION_NAME, $settings->toArray(), false );
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function sanitizeOption( $value ): array {
|
|
return Settings::fromArray( is_array( $value ) ? $value : array() )->toArray();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run: `composer test -- --filter SettingsTest`
|
|
|
|
Expected: PASS with 4 tests.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/Settings/Settings.php src/Settings/SettingsRepository.php tests/Unit/SettingsTest.php
|
|
git commit -m "feat: add typed plugin settings"
|
|
```
|
|
|
|
## Task 4: Add Option-Backed Logger
|
|
|
|
**Files:**
|
|
- Create: `src/Logging/LoggerInterface.php`
|
|
- Create: `src/Logging/OptionLogger.php`
|
|
- Create: `tests/Unit/OptionLoggerTest.php`
|
|
|
|
- [ ] **Step 1: Write failing logger tests**
|
|
|
|
Create `tests/Unit/OptionLoggerTest.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync\Tests\Unit;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use WPContentSync\Logging\OptionLogger;
|
|
|
|
class OptionLoggerTest extends TestCase {
|
|
protected function setUp(): void {
|
|
$GLOBALS['wpcs_test_options'] = array();
|
|
}
|
|
|
|
public function test_it_records_log_entries(): void {
|
|
$logger = new OptionLogger( 10 );
|
|
|
|
$logger->warning( 'Connection failed.', array( 'url' => 'https://example.test' ) );
|
|
|
|
$entries = get_option( OptionLogger::OPTION_NAME, array() );
|
|
|
|
self::assertCount( 1, $entries );
|
|
self::assertSame( 'warning', $entries[0]['level'] );
|
|
self::assertSame( 'Connection failed.', $entries[0]['message'] );
|
|
self::assertSame( 'https://example.test', $entries[0]['context']['url'] );
|
|
}
|
|
|
|
public function test_it_redacts_sensitive_context_values(): void {
|
|
$logger = new OptionLogger( 10 );
|
|
|
|
$logger->error(
|
|
'Authentication failed.',
|
|
array(
|
|
'application_password' => 'secret-value',
|
|
'token' => 'token-value',
|
|
'username' => 'admin',
|
|
)
|
|
);
|
|
|
|
$entries = get_option( OptionLogger::OPTION_NAME, array() );
|
|
|
|
self::assertSame( '[redacted]', $entries[0]['context']['application_password'] );
|
|
self::assertSame( '[redacted]', $entries[0]['context']['token'] );
|
|
self::assertSame( 'admin', $entries[0]['context']['username'] );
|
|
}
|
|
|
|
public function test_it_limits_retained_entries(): void {
|
|
$logger = new OptionLogger( 2 );
|
|
|
|
$logger->info( 'First' );
|
|
$logger->info( 'Second' );
|
|
$logger->info( 'Third' );
|
|
|
|
$entries = get_option( OptionLogger::OPTION_NAME, array() );
|
|
|
|
self::assertCount( 2, $entries );
|
|
self::assertSame( 'Second', $entries[0]['message'] );
|
|
self::assertSame( 'Third', $entries[1]['message'] );
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add option stubs to test bootstrap**
|
|
|
|
Modify `tests/bootstrap.php` by appending:
|
|
|
|
```php
|
|
if ( ! function_exists( 'get_option' ) ) {
|
|
function get_option( $name, $default = false ) {
|
|
return $GLOBALS['wpcs_test_options'][ $name ] ?? $default;
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'update_option' ) ) {
|
|
function update_option( $name, $value, $autoload = null ) {
|
|
$GLOBALS['wpcs_test_options'][ $name ] = $value;
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run test to verify it fails**
|
|
|
|
Run: `composer test -- --filter OptionLoggerTest`
|
|
|
|
Expected: FAIL because class `WPContentSync\Logging\OptionLogger` does not exist.
|
|
|
|
- [ ] **Step 4: Implement logger interface**
|
|
|
|
Create `src/Logging/LoggerInterface.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync\Logging;
|
|
|
|
interface LoggerInterface {
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public function error( string $message, array $context = array() ): void;
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public function warning( string $message, array $context = array() ): void;
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public function info( string $message, array $context = array() ): void;
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public function debug( string $message, array $context = array() ): void;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Implement option logger**
|
|
|
|
Create `src/Logging/OptionLogger.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync\Logging;
|
|
|
|
final class OptionLogger implements LoggerInterface {
|
|
public const OPTION_NAME = 'wpcs_logs';
|
|
|
|
private const SENSITIVE_KEYS = array(
|
|
'application_password',
|
|
'password',
|
|
'token',
|
|
'authorization',
|
|
'secret',
|
|
);
|
|
|
|
private int $max_entries;
|
|
|
|
public function __construct( int $max_entries = 200 ) {
|
|
$this->max_entries = max( 1, $max_entries );
|
|
}
|
|
|
|
public function error( string $message, array $context = array() ): void {
|
|
$this->log( 'error', $message, $context );
|
|
}
|
|
|
|
public function warning( string $message, array $context = array() ): void {
|
|
$this->log( 'warning', $message, $context );
|
|
}
|
|
|
|
public function info( string $message, array $context = array() ): void {
|
|
$this->log( 'info', $message, $context );
|
|
}
|
|
|
|
public function debug( string $message, array $context = array() ): void {
|
|
$this->log( 'debug', $message, $context );
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
private function log( string $level, string $message, array $context ): void {
|
|
$entries = get_option( self::OPTION_NAME, array() );
|
|
$entries = is_array( $entries ) ? $entries : array();
|
|
|
|
$entries[] = array(
|
|
'timestamp' => gmdate( 'c' ),
|
|
'level' => $level,
|
|
'message' => sanitize_text_field( $message ),
|
|
'context' => $this->redactContext( $context ),
|
|
);
|
|
|
|
if ( count( $entries ) > $this->max_entries ) {
|
|
$entries = array_slice( $entries, -1 * $this->max_entries );
|
|
}
|
|
|
|
update_option( self::OPTION_NAME, $entries, false );
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function redactContext( array $context ): array {
|
|
$redacted = array();
|
|
|
|
foreach ( $context as $key => $value ) {
|
|
$normalized_key = strtolower( (string) $key );
|
|
$redacted[ $key ] = in_array( $normalized_key, self::SENSITIVE_KEYS, true )
|
|
? '[redacted]'
|
|
: $value;
|
|
}
|
|
|
|
return $redacted;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run tests to verify they pass**
|
|
|
|
Run: `composer test -- --filter OptionLoggerTest`
|
|
|
|
Expected: PASS with 3 tests.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/Logging/LoggerInterface.php src/Logging/OptionLogger.php tests/Unit/OptionLoggerTest.php tests/bootstrap.php
|
|
git commit -m "feat: add bounded option logger"
|
|
```
|
|
|
|
## Task 5: Add Plugin Bootstrap and Lifecycle Hooks
|
|
|
|
**Files:**
|
|
- Create: `wp-content-sync.php`
|
|
- Create: `src/Plugin.php`
|
|
- Create: `src/Activator.php`
|
|
- Create: `src/Deactivator.php`
|
|
|
|
- [ ] **Step 1: Create the plugin entrypoint**
|
|
|
|
Create `wp-content-sync.php`:
|
|
|
|
```php
|
|
<?php
|
|
/**
|
|
* Plugin Name: WP Content Sync
|
|
* Description: Bidirectional content synchronization between WordPress instances.
|
|
* Version: 0.1.0
|
|
* Requires at least: 5.6
|
|
* Requires PHP: 7.4
|
|
* Author: WP Content Sync Contributors
|
|
* License: GPL-2.0-or-later
|
|
* Text Domain: wp-content-sync
|
|
*
|
|
* @package WPContentSync
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
define( 'WPCS_PLUGIN_FILE', __FILE__ );
|
|
define( 'WPCS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
|
define( 'WPCS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
|
define( 'WPCS_VERSION', '0.1.0' );
|
|
|
|
$wpcs_autoload = __DIR__ . '/vendor/autoload.php';
|
|
|
|
if ( file_exists( $wpcs_autoload ) ) {
|
|
require_once $wpcs_autoload;
|
|
}
|
|
|
|
register_activation_hook( __FILE__, array( \WPContentSync\Activator::class, 'activate' ) );
|
|
register_deactivation_hook( __FILE__, array( \WPContentSync\Deactivator::class, 'deactivate' ) );
|
|
|
|
add_action(
|
|
'plugins_loaded',
|
|
static function (): void {
|
|
$plugin = \WPContentSync\Plugin::create();
|
|
$plugin->register();
|
|
}
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 2: Implement activator**
|
|
|
|
Create `src/Activator.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync;
|
|
|
|
use WPContentSync\Settings\Settings;
|
|
use WPContentSync\Settings\SettingsRepository;
|
|
|
|
final class Activator {
|
|
public static function activate(): void {
|
|
if ( false === get_option( SettingsRepository::OPTION_NAME, false ) ) {
|
|
update_option( SettingsRepository::OPTION_NAME, Settings::fromArray( array() )->toArray(), false );
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Implement deactivator**
|
|
|
|
Create `src/Deactivator.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync;
|
|
|
|
final class Deactivator {
|
|
public static function deactivate(): void {
|
|
delete_transient( 'wpcs_active_operation' );
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement plugin lifecycle coordinator**
|
|
|
|
Create `src/Plugin.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync;
|
|
|
|
use WPContentSync\Admin\AdminPage;
|
|
use WPContentSync\Logging\LoggerInterface;
|
|
use WPContentSync\Logging\OptionLogger;
|
|
use WPContentSync\Settings\SettingsRepository;
|
|
|
|
final class Plugin {
|
|
private Container $container;
|
|
|
|
private function __construct( Container $container ) {
|
|
$this->container = $container;
|
|
}
|
|
|
|
public static function create(): self {
|
|
$container = new Container();
|
|
|
|
$container->factory(
|
|
SettingsRepository::class,
|
|
static function (): SettingsRepository {
|
|
return new SettingsRepository();
|
|
}
|
|
);
|
|
|
|
$container->factory(
|
|
LoggerInterface::class,
|
|
static function (): LoggerInterface {
|
|
return new OptionLogger();
|
|
}
|
|
);
|
|
|
|
$container->factory(
|
|
AdminPage::class,
|
|
static function () use ( $container ): AdminPage {
|
|
return new AdminPage(
|
|
$container->get( SettingsRepository::class ),
|
|
$container->get( LoggerInterface::class )
|
|
);
|
|
}
|
|
);
|
|
|
|
return new self( $container );
|
|
}
|
|
|
|
public function register(): void {
|
|
$this->container->get( AdminPage::class )->register();
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Add missing transient stub to test bootstrap**
|
|
|
|
Modify `tests/bootstrap.php` by appending:
|
|
|
|
```php
|
|
if ( ! function_exists( 'delete_transient' ) ) {
|
|
function delete_transient( $name ) {
|
|
unset( $GLOBALS['wpcs_test_transients'][ $name ] );
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run static checks for lifecycle files**
|
|
|
|
Run: `composer stan`
|
|
|
|
Expected: PASS with no PHPStan errors for `src` and `wp-content-sync.php`.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add wp-content-sync.php src/Plugin.php src/Activator.php src/Deactivator.php tests/bootstrap.php
|
|
git commit -m "feat: add plugin bootstrap lifecycle"
|
|
```
|
|
|
|
## Task 6: Add Admin Dashboard Shell
|
|
|
|
**Files:**
|
|
- Create: `src/Admin/AdminPage.php`
|
|
- Create: `templates/admin/dashboard.php`
|
|
- Modify: `src/Plugin.php`
|
|
- Modify: `tests/bootstrap.php`
|
|
|
|
- [ ] **Step 1: Implement admin page controller**
|
|
|
|
Create `src/Admin/AdminPage.php`:
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace WPContentSync\Admin;
|
|
|
|
use WPContentSync\Logging\LoggerInterface;
|
|
use WPContentSync\Settings\SettingsRepository;
|
|
|
|
final class AdminPage {
|
|
private SettingsRepository $settings_repository;
|
|
private LoggerInterface $logger;
|
|
|
|
public function __construct( SettingsRepository $settings_repository, LoggerInterface $logger ) {
|
|
$this->settings_repository = $settings_repository;
|
|
$this->logger = $logger;
|
|
}
|
|
|
|
public function register(): void {
|
|
add_action( 'admin_menu', array( $this, 'registerMenu' ) );
|
|
add_action( 'admin_init', array( $this, 'registerSettings' ) );
|
|
}
|
|
|
|
public function registerMenu(): void {
|
|
add_management_page(
|
|
__( 'WP Content Sync', 'wp-content-sync' ),
|
|
__( 'Content Sync', 'wp-content-sync' ),
|
|
'manage_options',
|
|
'wp-content-sync',
|
|
array( $this, 'render' )
|
|
);
|
|
}
|
|
|
|
public function registerSettings(): void {
|
|
register_setting(
|
|
'wpcs_settings',
|
|
SettingsRepository::OPTION_NAME,
|
|
array(
|
|
'type' => 'array',
|
|
'sanitize_callback' => array( $this->settings_repository, 'sanitizeOption' ),
|
|
'default' => $this->settings_repository->get()->toArray(),
|
|
)
|
|
);
|
|
}
|
|
|
|
public function render(): void {
|
|
if ( ! current_user_can( 'manage_options' ) ) {
|
|
wp_die( esc_html__( 'You do not have permission to access WP Content Sync.', 'wp-content-sync' ) );
|
|
}
|
|
|
|
$settings = $this->settings_repository->get();
|
|
$this->logger->debug( 'Admin dashboard viewed.' );
|
|
|
|
include WPCS_PLUGIN_DIR . 'templates/admin/dashboard.php';
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create escaped dashboard template**
|
|
|
|
Create `templates/admin/dashboard.php`:
|
|
|
|
```php
|
|
<?php
|
|
/**
|
|
* Admin dashboard template.
|
|
*
|
|
* @package WPContentSync
|
|
*
|
|
* @var \WPContentSync\Settings\Settings $settings
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
?>
|
|
<div class="wrap">
|
|
<h1><?php echo esc_html__( 'WP Content Sync', 'wp-content-sync' ); ?></h1>
|
|
<p><?php echo esc_html__( 'Configure sync pairs, test connections, and run push or pull operations from this screen.', 'wp-content-sync' ); ?></p>
|
|
|
|
<div class="notice notice-info">
|
|
<p>
|
|
<?php
|
|
echo esc_html__(
|
|
'The foundation is installed. Sync pair management, URL mappings, file transport, and REST transport will be added in upcoming implementation phases.',
|
|
'wp-content-sync'
|
|
);
|
|
?>
|
|
</p>
|
|
</div>
|
|
|
|
<h2><?php echo esc_html__( 'Current Defaults', 'wp-content-sync' ); ?></h2>
|
|
<table class="widefat striped">
|
|
<tbody>
|
|
<tr>
|
|
<th scope="row"><?php echo esc_html__( 'Configured Sync Pairs', 'wp-content-sync' ); ?></th>
|
|
<td><?php echo esc_html( (string) count( $settings->syncPairs() ) ); ?></td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="row"><?php echo esc_html__( 'Logging Level', 'wp-content-sync' ); ?></th>
|
|
<td><?php echo esc_html( $settings->loggingLevel() ); ?></td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="row"><?php echo esc_html__( 'URL Replacement', 'wp-content-sync' ); ?></th>
|
|
<td>
|
|
<?php
|
|
echo esc_html(
|
|
$settings->automaticUrlReplacementEnabled()
|
|
? __( 'Enabled', 'wp-content-sync' )
|
|
: __( 'Disabled', 'wp-content-sync' )
|
|
);
|
|
?>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th scope="row"><?php echo esc_html__( 'Conflict Strategy', 'wp-content-sync' ); ?></th>
|
|
<td><?php echo esc_html( $settings->conflictStrategy() ); ?></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 3: Add admin WordPress stubs for static/unit runs**
|
|
|
|
Modify `tests/bootstrap.php` by appending:
|
|
|
|
```php
|
|
if ( ! function_exists( '__' ) ) {
|
|
function __( $text, $domain = 'default' ) {
|
|
return $text;
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'esc_html__' ) ) {
|
|
function esc_html__( $text, $domain = 'default' ) {
|
|
return esc_html( $text );
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'add_action' ) ) {
|
|
function add_action( $hook_name, $callback ) {
|
|
$GLOBALS['wpcs_test_actions'][ $hook_name ][] = $callback;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'add_management_page' ) ) {
|
|
function add_management_page( $page_title, $menu_title, $capability, $menu_slug, $callback ) {
|
|
$GLOBALS['wpcs_test_admin_pages'][ $menu_slug ] = compact( 'page_title', 'menu_title', 'capability', 'callback' );
|
|
return $menu_slug;
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'register_setting' ) ) {
|
|
function register_setting( $option_group, $option_name, $args = array() ) {
|
|
$GLOBALS['wpcs_test_registered_settings'][ $option_name ] = compact( 'option_group', 'args' );
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'current_user_can' ) ) {
|
|
function current_user_can( $capability ) {
|
|
return 'manage_options' === $capability;
|
|
}
|
|
}
|
|
|
|
if ( ! function_exists( 'wp_die' ) ) {
|
|
function wp_die( $message ) {
|
|
throw new \RuntimeException( (string) $message );
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run lint and static analysis**
|
|
|
|
Run: `composer lint`
|
|
|
|
Expected: PASS with no PHPCS violations.
|
|
|
|
Run: `composer stan`
|
|
|
|
Expected: PASS with no PHPStan errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/Admin/AdminPage.php templates/admin/dashboard.php tests/bootstrap.php
|
|
git commit -m "feat: add admin dashboard shell"
|
|
```
|
|
|
|
## Task 7: Run Full Foundation Verification
|
|
|
|
**Files:**
|
|
- Verify all files created in Tasks 1-6.
|
|
|
|
- [ ] **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 all unit tests.
|
|
|
|
- [ ] **Step 5: Perform manual WordPress smoke test**
|
|
|
|
Install the plugin directory into a WordPress 5.6+ site and activate WP Content Sync from the Plugins screen.
|
|
|
|
Expected:
|
|
- Activation completes without fatal errors.
|
|
- A `Content Sync` item appears under `Tools`.
|
|
- Opening `Tools > Content Sync` shows the dashboard shell.
|
|
- The dashboard shows `Configured Sync Pairs` as `0`, `Logging Level` as `warning`, `URL Replacement` as `Enabled`, and `Conflict Strategy` as `last_write_wins`.
|
|
|
|
- [ ] **Step 6: Commit final verification note if code changed during fixes**
|
|
|
|
```bash
|
|
git status --short
|
|
git add .
|
|
git commit -m "chore: verify plugin foundation"
|
|
```
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage in this foundation plan:**
|
|
- Covers initial admin interface shell, settings persistence, secure defaults, logging boundary, plugin lifecycle, and project tooling.
|
|
- Defers sync engine, content handlers, URL transformation, REST transport, file transport, conflict resolution, and background progress to later roadmap phases.
|
|
- Security requirements begin here through capability checks, escaped admin output, sanitized settings, and log redaction.
|
|
|
|
**Placeholder scan:** This plan intentionally avoids placeholder implementation steps. Deferred product areas are listed in the roadmap as later plans rather than vague steps inside this plan.
|
|
|
|
**Type consistency:** `SettingsRepository`, `Settings`, `LoggerInterface`, `OptionLogger`, `AdminPage`, `Container`, `Activator`, `Deactivator`, and `Plugin` names are consistent across tasks.
|