feat: scaffold plugin foundation

This commit is contained in:
Keith Solomon
2026-04-26 12:44:16 -05:00
commit 557657344d
24 changed files with 5238 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
<?php
/**
* Plugin activation lifecycle hook.
*
* @package WPContentSync
*/
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 );
}
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* Admin dashboard controller.
*
* @package WPContentSync
*/
// phpcs:disable WordPress.Files.FileName.InvalidClassFileName
namespace WPContentSync\Admin;
use WPContentSync\Logging\LoggerInterface;
use WPContentSync\Settings\SettingsRepository;
/**
* Registers and renders the WP Content Sync admin page.
*/
final class AdminPage {
/**
* Settings storage.
*
* @var SettingsRepository
*/
private SettingsRepository $settings_repository;
/**
* Plugin logger.
*
* @var LoggerInterface
*/
private LoggerInterface $logger;
/**
* @param SettingsRepository $settings_repository Settings storage.
* @param LoggerInterface $logger Plugin logger.
*/
public function __construct( SettingsRepository $settings_repository, LoggerInterface $logger ) {
$this->settings_repository = $settings_repository;
$this->logger = $logger;
}
/**
* Registers admin hooks.
*/
public function register(): void {
add_action( 'admin_menu', array( $this, 'registerMenu' ) );
add_action( 'admin_init', array( $this, 'registerSettings' ) );
}
/**
* Registers the Tools menu page.
*/
// phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
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' )
);
}
/**
* Registers the plugin settings option.
*/
// phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
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(),
)
);
}
/**
* Renders the admin dashboard.
*/
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';
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace WPContentSync;
final class Container {
/**
* @var array<string, mixed>
*/
private array $services = array();
/**
* @var array<string, callable(): mixed>
*/
private array $factories = array();
/**
* @param mixed $service Service instance or value.
*/
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;
}
/**
* @return mixed
*/
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 ) );
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
/**
* Plugin deactivation lifecycle hook.
*
* @package WPContentSync
*/
namespace WPContentSync;
final class Deactivator {
public static function deactivate(): void {
delete_transient( 'wpcs_active_operation' );
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace WPContentSync\Logging;
interface LoggerInterface {
/**
* @param array<string, mixed> $context Log context.
*/
public function error( string $message, array $context = array() ): void;
/**
* @param array<string, mixed> $context Log context.
*/
public function warning( string $message, array $context = array() ): void;
/**
* @param array<string, mixed> $context Log context.
*/
public function info( string $message, array $context = array() ): void;
/**
* @param array<string, mixed> $context Log context.
*/
public function debug( string $message, array $context = array() ): void;
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace WPContentSync\Logging;
final class OptionLogger implements LoggerInterface {
public const OPTION_NAME = 'wpcs_logs';
private const SENSITIVE_KEYS = array(
'auth',
'authorization',
'password',
'secret',
'token',
);
private int $max_entries;
public function __construct( int $max_entries = 200 ) {
$this->max_entries = max( 1, $max_entries );
}
/**
* @param array<string, mixed> $context Log context.
*/
public function error( string $message, array $context = array() ): void {
$this->log( 'error', $message, $context );
}
/**
* @param array<string, mixed> $context Log context.
*/
public function warning( string $message, array $context = array() ): void {
$this->log( 'warning', $message, $context );
}
/**
* @param array<string, mixed> $context Log context.
*/
public function info( string $message, array $context = array() ): void {
$this->log( 'info', $message, $context );
}
/**
* @param array<string, mixed> $context Log context.
*/
public function debug( string $message, array $context = array() ): void {
$this->log( 'debug', $message, $context );
}
/**
* @param array<string, mixed> $context Log 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 Log context.
* @return array<string, mixed>
*/
private function redactContext( array $context ): array {
$redacted = array();
foreach ( $context as $key => $value ) {
$redacted[ $key ] = $this->isSensitiveKey( (string) $key )
? '[redacted]'
: $this->redactValue( $value );
}
return $redacted;
}
private function isSensitiveKey( string $key ): bool {
$normalized_key = strtolower( $key );
foreach ( self::SENSITIVE_KEYS as $sensitive_key ) {
if ( false !== strpos( $normalized_key, $sensitive_key ) ) {
return true;
}
}
return false;
}
/**
* @param mixed $value Context value.
* @return mixed
*/
private function redactValue( $value ) {
return is_array( $value ) ? $this->redactContext( $value ) : $value;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
/**
* Plugin lifecycle coordinator.
*
* @package WPContentSync
*/
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 {
/** @var AdminPage $admin_page */
$admin_page = $this->container->get( AdminPage::class );
$admin_page->register();
}
}
+148
View File
@@ -0,0 +1,148 @@
<?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 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 Raw settings 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 )
? self::sanitizeBoolean( $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 Value to sanitize.
* @param array<int, string> $allowed Allowed values.
*/
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 $value Value to normalize.
*/
private static function sanitizeBoolean( $value ): bool {
if ( is_bool( $value ) ) {
return $value;
}
$normalized = strtolower( sanitize_text_field( (string) $value ) );
return in_array( $normalized, array( '1', 'true', 'yes', 'on' ), true );
}
/**
* @param mixed $pairs Raw sync 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_raw( (string) ( $pair['source_url'] ?? '' ) );
$destination_url = esc_url_raw( (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;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?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 Value to sanitize.
* @return array<string, mixed>
*/
public function sanitizeOption( $value ): array {
return Settings::fromArray( is_array( $value ) ? $value : array() )->toArray();
}
}