32 KiB
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.phpas the WordPress plugin entrypoint. - Create:
composer.jsonfor autoloading, dev dependencies, and scripts. - Create:
phpcs.xmlfor WordPress-oriented coding standards. - Create:
phpstan.neonfor level 6 static analysis. - Create:
phpunit.xml.distfor unit test configuration. - Create:
src/Plugin.phpas the lifecycle coordinator. - Create:
src/Container.phpas the lightweight service registry. - Create:
src/Activator.phpto install default options on activation. - Create:
src/Deactivator.phpto perform safe deactivation cleanup. - Create:
src/Settings/Settings.phpas a typed settings value object. - Create:
src/Settings/SettingsRepository.phpas the WordPress option boundary. - Create:
src/Logging/LoggerInterface.phpas the logging boundary. - Create:
src/Logging/OptionLogger.phpas the initial bounded option-backed logger. - Create:
src/Admin/AdminPage.phpas the admin dashboard controller. - Create:
templates/admin/dashboard.phpas the escaped dashboard view. - Create:
tests/bootstrap.phpfor PHPUnit bootstrap and WordPress function stubs. - Create:
tests/Unit/SettingsTest.phpfor settings defaults and sanitization behavior. - Create:
tests/Unit/ContainerTest.phpfor service registration behavior. - Create:
tests/Unit/OptionLoggerTest.phpfor 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:
{
"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 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:
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 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
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
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
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
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
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
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
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
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
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
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:
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
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
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
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
/**
* 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
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
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
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:
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
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
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
/**
* 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:
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
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 Syncitem appears underTools. -
Opening
Tools > Content Syncshows the dashboard shell. -
The dashboard shows
Configured Sync Pairsas0,Logging Levelaswarning,URL ReplacementasEnabled, andConflict Strategyaslast_write_wins. -
Step 6: Commit final verification note if code changed during fixes
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.