fix(structure): Correct case-sensitivity for Linux compatibility

Rename folders to match PHP PSR-4 autoloading conventions:
- schemas → Schemas
- shared → Shared
- Wordpress → WordPress (in all locations)

Fixes deployment issues on Linux servers where filesystem is case-sensitive.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-11-26 22:53:34 -06:00
parent a2548ab5c2
commit 90863cd8f5
92 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Adapters;
use ROITheme\Component\Infrastructure\Di\DIContainer;
use ROITheme\Shared\Domain\ValueObjects\ComponentName;
use ROITheme\Shared\Domain\ValueObjects\ComponentConfiguration;
use ROITheme\Shared\Domain\Contracts\ComponentRepositoryInterface;
use ROITheme\Shared\Domain\Contracts\DefaultRepositoryInterface;
/**
* LegacyDBManagerAdapter - Adapter para ROI_DB_Manager deprecated
*
* RESPONSABILIDAD: Traducir llamadas legacy a la nueva arquitectura
*
* PATRÓN: Adapter Pattern
* - Interfaz antigua (ROI_DB_Manager methods)
* - Implementación nueva (Clean Architecture repositories)
*
* Este adapter mantiene compatibilidad backward mientras se migra el código
*
* @package ROITheme\Infrastructure\Adapters
*/
final class LegacyDBManagerAdapter
{
private ComponentRepositoryInterface $componentRepository;
private ComponentDefaultsRepositoryInterface $defaultsRepository;
public function __construct()
{
global $wpdb;
// Get repositories from DI Container
$schemasPath = get_template_directory() . '/schemas';
$container = new DIContainer($wpdb, $schemasPath);
$this->componentRepository = $container->getComponentRepository();
$this->defaultsRepository = $container->getDefaultsRepository();
}
/**
* Save component configuration (legacy method)
*
* Adapta save_config() legacy a WordPressComponentRepository::save()
*
* @param string $component_name Component name
* @param string $config_key Configuration key
* @param mixed $config_value Configuration value
* @param string $data_type Data type
* @param string|null $version Schema version
* @param string $table_type Table type (components or defaults)
* @return bool Success status
*/
public function save_config(
string $component_name,
string $config_key,
$config_value,
string $data_type = 'string',
?string $version = null,
string $table_type = 'components'
): bool {
try {
$componentNameVO = new ComponentName($component_name);
if ($table_type === 'defaults') {
// Save to defaults repository
$config = $this->defaultsRepository->exists($componentNameVO)
? $this->defaultsRepository->getByName($componentNameVO)
: ComponentConfiguration::fromArray([]);
$data = $config->toArray();
$data[$config_key] = $this->convertValue($config_value, $data_type);
$newConfig = ComponentConfiguration::fromArray($data);
$this->defaultsRepository->save($componentNameVO, $newConfig);
return true;
}
// Save to component repository
$component = $this->componentRepository->findByName($componentNameVO);
if ($component === null) {
// Create new component with this configuration
$data = [
'visibility' => ['is_enabled' => true],
'content' => [$config_key => $this->convertValue($config_value, $data_type)],
'styles' => [],
'config' => []
];
$newComponent = new \ROITheme\Domain\Component\Component(
$component_name,
$component_name,
$data
);
$this->componentRepository->save($newComponent);
} else {
// Update existing component
$data = $component->getData();
// Determinar en qué sección guardar
$section = $this->determineSection($config_key);
if (!isset($data[$section])) {
$data[$section] = [];
}
$data[$section][$config_key] = $this->convertValue($config_value, $data_type);
$updatedComponent = $component->withData($data);
$this->componentRepository->save($updatedComponent);
}
return true;
} catch (\Exception $e) {
// Log error but maintain backward compatibility
error_log("LegacyDBManagerAdapter::save_config error: " . $e->getMessage());
return false;
}
}
/**
* Get component configuration (legacy method)
*
* Adapta get_config() legacy a WordPressComponentRepository::findByName()
*
* @param string $component_name Component name
* @param string|null $config_key Specific configuration key (null for all)
* @param string $table_type Table type (components or defaults)
* @return mixed Configuration value(s) or null
*/
public function get_config(
string $component_name,
?string $config_key = null,
string $table_type = 'components'
) {
try {
$componentNameVO = new ComponentName($component_name);
if ($table_type === 'defaults') {
if (!$this->defaultsRepository->exists($componentNameVO)) {
return null;
}
$config = $this->defaultsRepository->getByName($componentNameVO);
$data = $config->toArray();
return $config_key ? ($data[$config_key] ?? null) : $data;
}
// Get from component repository
$component = $this->componentRepository->findByName($componentNameVO);
if ($component === null) {
return null;
}
$data = $component->getData();
if ($config_key === null) {
// Return all configuration
return $this->flattenComponentData($data);
}
// Search for key in all sections
foreach (['visibility', 'content', 'styles', 'config'] as $section) {
if (isset($data[$section][$config_key])) {
return $data[$section][$config_key];
}
}
return null;
} catch (\Exception $e) {
error_log("LegacyDBManagerAdapter::get_config error: " . $e->getMessage());
return null;
}
}
/**
* Delete component configuration (legacy method)
*
* Adapta delete_config() legacy a WordPressComponentRepository::delete()
*
* @param string $component_name Component name
* @param string $table_type Table type (components or defaults)
* @return bool Success status
*/
public function delete_config(string $component_name, string $table_type = 'components'): bool
{
try {
$componentNameVO = new ComponentName($component_name);
if ($table_type === 'defaults') {
return $this->defaultsRepository->delete($componentNameVO);
}
return $this->componentRepository->delete($componentNameVO);
} catch (\Exception $e) {
error_log("LegacyDBManagerAdapter::delete_config error: " . $e->getMessage());
return false;
}
}
/**
* Get component by table (legacy method)
*
* @param string $component_name Component name
* @param string $table_type Table type
* @return array|null Component data or null
*/
public function get_component_by_table(string $component_name, string $table_type = 'components'): ?array
{
try {
$componentNameVO = new ComponentName($component_name);
if ($table_type === 'defaults') {
if (!$this->defaultsRepository->exists($componentNameVO)) {
return null;
}
$config = $this->defaultsRepository->getByName($componentNameVO);
return [
'component_name' => $component_name,
'default_schema' => json_encode($config->toArray()),
'created_at' => '',
'updated_at' => ''
];
}
$component = $this->componentRepository->findByName($componentNameVO);
if ($component === null) {
return null;
}
return [
'component_name' => $component_name,
'configuration' => json_encode($component->getData()),
'is_enabled' => $component->isEnabled() ? 1 : 0,
'created_at' => '',
'updated_at' => ''
];
} catch (\Exception $e) {
error_log("LegacyDBManagerAdapter::get_component_by_table error: " . $e->getMessage());
return null;
}
}
/**
* Convert value to appropriate type
*
* @param mixed $value Value to convert
* @param string $type Target type
* @return mixed Converted value
*/
private function convertValue($value, string $type)
{
switch ($type) {
case 'boolean':
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
case 'integer':
return (int) $value;
case 'array':
return is_array($value) ? $value : json_decode($value, true);
case 'string':
default:
return (string) $value;
}
}
/**
* Determine which section a config key belongs to
*
* @param string $key Configuration key
* @return string Section name
*/
private function determineSection(string $key): string
{
// Visibility keys
if (in_array($key, ['is_enabled', 'sticky', 'show_on_mobile', 'show_on_desktop'])) {
return 'visibility';
}
// Style keys
if (strpos($key, 'color') !== false || strpos($key, 'font') !== false || strpos($key, 'size') !== false) {
return 'styles';
}
// Config keys
if (in_array($key, ['auto_close', 'close_delay', 'animation'])) {
return 'config';
}
// Default to content
return 'content';
}
/**
* Flatten component data for legacy compatibility
*
* @param array $data Component data
* @return array Flattened data
*/
private function flattenComponentData(array $data): array
{
$flattened = [];
foreach ($data as $section => $values) {
if (is_array($values)) {
foreach ($values as $key => $value) {
$flattened[$key] = $value;
}
}
}
return $flattened;
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Api\Wordpress;
use ROITheme\Shared\Application\UseCases\SaveComponent\SaveComponentUseCase;
use ROITheme\Shared\Application\UseCases\SaveComponent\SaveComponentRequest;
use ROITheme\Shared\Application\UseCases\GetComponent\GetComponentUseCase;
use ROITheme\Shared\Application\UseCases\GetComponent\GetComponentRequest;
use ROITheme\Shared\Application\UseCases\DeleteComponent\DeleteComponentUseCase;
use ROITheme\Shared\Application\UseCases\DeleteComponent\DeleteComponentRequest;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
/**
* AjaxController - Endpoints AJAX de WordPress
*
* RESPONSABILIDAD: Manejar HTTP (request/response)
*
* PRINCIPIOS:
* - Single Responsibility: Solo maneja HTTP, delega lógica a Use Cases
* - Dependency Inversion: Depende de Use Cases (Application)
*
* SEGURIDAD:
* - Verifica nonce
* - Verifica capabilities (manage_options)
* - Sanitiza inputs
*
* @package ROITheme\Infrastructure\Api\WordPress
*/
final class AjaxController
{
private const NONCE_ACTION = 'roi_theme_admin_nonce';
public function __construct(
private DIContainer $container
) {}
/**
* Registrar hooks de WordPress
*/
public function register(): void
{
add_action('wp_ajax_roi_theme_save_component', [$this, 'saveComponent']);
add_action('wp_ajax_roi_theme_get_component', [$this, 'getComponent']);
add_action('wp_ajax_roi_theme_delete_component', [$this, 'deleteComponent']);
add_action('wp_ajax_roi_theme_sync_schema', [$this, 'syncSchema']);
}
/**
* Endpoint: Guardar componente
*
* POST /wp-admin/admin-ajax.php?action=roi_theme_save_component
* Body: { nonce, component_name, data }
*
* @return void (usa wp_send_json_*)
*/
public function saveComponent(): void
{
// 1. Seguridad
if (!$this->verifySecurity()) {
wp_send_json_error([
'message' => 'Security check failed'
], 403);
return;
}
// 2. Sanitizar input
$componentName = sanitize_key($_POST['component_name'] ?? '');
$data = $_POST['data'] ?? [];
if (empty($componentName)) {
wp_send_json_error([
'message' => 'Component name is required'
], 400);
return;
}
// 3. Crear Request DTO
$request = new SaveComponentRequest($componentName, $data);
// 4. Ejecutar Use Case
$useCase = new SaveComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getValidationService(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
// 5. Respuesta HTTP
if ($response->isSuccess()) {
wp_send_json_success($response->getData());
} else {
wp_send_json_error([
'message' => 'Validation failed',
'errors' => $response->getErrors()
], 422);
}
}
/**
* Endpoint: Obtener componente
*
* GET /wp-admin/admin-ajax.php?action=roi_theme_get_component&component_name=xxx
*/
public function getComponent(): void
{
if (!$this->verifySecurity()) {
wp_send_json_error(['message' => 'Security check failed'], 403);
return;
}
$componentName = sanitize_key($_GET['component_name'] ?? '');
if (empty($componentName)) {
wp_send_json_error(['message' => 'Component name is required'], 400);
return;
}
$request = new GetComponentRequest($componentName);
$useCase = new GetComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
if ($response->isSuccess()) {
wp_send_json_success($response->getData());
} else {
wp_send_json_error(['message' => $response->getError()], 404);
}
}
/**
* Endpoint: Eliminar componente
*
* POST /wp-admin/admin-ajax.php?action=roi_theme_delete_component
* Body: { nonce, component_name }
*/
public function deleteComponent(): void
{
if (!$this->verifySecurity()) {
wp_send_json_error(['message' => 'Security check failed'], 403);
return;
}
$componentName = sanitize_key($_POST['component_name'] ?? '');
if (empty($componentName)) {
wp_send_json_error(['message' => 'Component name is required'], 400);
return;
}
$request = new DeleteComponentRequest($componentName);
$useCase = new DeleteComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
if ($response->isSuccess()) {
wp_send_json_success(['message' => $response->getMessage()]);
} else {
wp_send_json_error(['message' => $response->getError()], 404);
}
}
/**
* Endpoint: Sincronizar schemas
*
* POST /wp-admin/admin-ajax.php?action=roi_theme_sync_schema
*/
public function syncSchema(): void
{
if (!$this->verifySecurity()) {
wp_send_json_error(['message' => 'Security check failed'], 403);
return;
}
$syncService = $this->container->getSchemaSyncService();
$result = $syncService->syncAll();
if ($result['success']) {
wp_send_json_success($result['data']);
} else {
wp_send_json_error(['message' => $result['error']], 500);
}
}
/**
* Verificar seguridad (nonce + capabilities)
*
* @return bool
*/
private function verifySecurity(): bool
{
// Verificar nonce
$nonce = $_REQUEST['nonce'] ?? '';
if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) {
return false;
}
// Verificar permisos (solo administradores)
if (!current_user_can('manage_options')) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Api\Wordpress;
/**
* WP-CLI Command para Sincronización de Schemas
*
* Responsabilidad: Sincronizar schemas JSON a base de datos
*
* COMANDOS DISPONIBLES:
* - wp roi-theme sync-component [nombre] : Sincronizar un componente específico
* - wp roi-theme sync-all-components : Sincronizar todos los componentes
*
* USO:
* ```bash
* # Sincronizar un componente específico
* wp roi-theme sync-component top-notification-bar
*
* # Sincronizar todos los componentes
* wp roi-theme sync-all-components
* ```
*
* IMPORTANTE:
* - Si un campo NO existe en BD → INSERT con valor default del JSON
* - Si un campo YA existe → PRESERVA el valor del usuario, solo actualiza is_editable
*/
final class MigrationCommand
{
/**
* Sincronizar un componente específico desde su schema JSON a la BD
*
* ## OPCIONES
*
* <component_name>
* : Nombre del componente a sincronizar (sin extensión .json)
*
* ## EJEMPLOS
*
* # Sincronizar top-notification-bar
* wp roi-theme sync-component top-notification-bar
*
* # Sincronizar navbar
* wp roi-theme sync-component navbar
*
* @param array $args Argumentos posicionales
* @param array $assoc_args Argumentos asociativos (--flags)
* @return void
*/
public function sync_component(array $args, array $assoc_args): void
{
if (empty($args[0])) {
\WP_CLI::error('Debes especificar el nombre del componente. Ejemplo: wp roi-theme sync-component top-notification-bar');
return;
}
$component_name = $args[0];
$schemas_path = get_template_directory() . '/schemas';
$schema_file = $schemas_path . '/' . $component_name . '.json';
if (!file_exists($schema_file)) {
\WP_CLI::error("Schema no encontrado: {$schema_file}");
return;
}
\WP_CLI::line('');
\WP_CLI::line("🔄 Sincronizando componente: {$component_name}");
\WP_CLI::line('');
$result = $this->syncSchemaToDatabase($schema_file, $component_name);
\WP_CLI::line('');
if ($result['success']) {
\WP_CLI::success($result['message']);
\WP_CLI::line('');
\WP_CLI::line('📊 Estadísticas:');
\WP_CLI::line(' ├─ Campos insertados: ' . $result['stats']['inserted']);
\WP_CLI::line(' ├─ Campos actualizados: ' . $result['stats']['updated']);
\WP_CLI::line(' ├─ Campos eliminados (obsoletos): ' . $result['stats']['deleted']);
\WP_CLI::line(' └─ Total en schema: ' . $result['stats']['total']);
\WP_CLI::line('');
} else {
\WP_CLI::error($result['message']);
}
}
/**
* Sincronizar todos los componentes desde schemas/ a la BD
*
* ## EJEMPLOS
*
* wp roi-theme sync-all-components
*
* @param array $args Argumentos posicionales
* @param array $assoc_args Argumentos asociativos
* @return void
*/
public function sync_all_components(array $args, array $assoc_args): void
{
$schemas_path = get_template_directory() . '/schemas';
if (!is_dir($schemas_path)) {
\WP_CLI::error("Directorio de schemas no encontrado: {$schemas_path}");
return;
}
$schema_files = glob($schemas_path . '/*.json');
if (empty($schema_files)) {
\WP_CLI::warning('No se encontraron schemas JSON en ' . $schemas_path);
return;
}
\WP_CLI::line('');
\WP_CLI::line('🔄 Sincronizando todos los componentes...');
\WP_CLI::line('');
$total_stats = [
'components' => 0,
'inserted' => 0,
'updated' => 0,
'total' => 0,
'errors' => 0
];
foreach ($schema_files as $schema_file) {
$component_name = basename($schema_file, '.json');
\WP_CLI::line(" → Procesando: {$component_name}");
$result = $this->syncSchemaToDatabase($schema_file, $component_name);
if ($result['success']) {
$total_stats['components']++;
$total_stats['inserted'] += $result['stats']['inserted'];
$total_stats['updated'] += $result['stats']['updated'];
$total_stats['total'] += $result['stats']['total'];
\WP_CLI::line("{$result['message']}");
} else {
$total_stats['errors']++;
\WP_CLI::line(" ✗ Error: {$result['message']}");
}
}
\WP_CLI::line('');
\WP_CLI::success('Sincronización completada');
\WP_CLI::line('');
\WP_CLI::line('📊 Estadísticas totales:');
\WP_CLI::line(' ├─ Componentes sincronizados: ' . $total_stats['components']);
\WP_CLI::line(' ├─ Campos insertados: ' . $total_stats['inserted']);
\WP_CLI::line(' ├─ Campos actualizados: ' . $total_stats['updated']);
\WP_CLI::line(' ├─ Total procesado: ' . $total_stats['total']);
\WP_CLI::line(' └─ Errores: ' . $total_stats['errors']);
\WP_CLI::line('');
}
/**
* Sincronizar un schema JSON a la base de datos
*
* @param string $schema_file Ruta completa al archivo JSON
* @param string $component_name Nombre del componente
* @return array{success: bool, message: string, stats: array{inserted: int, updated: int, deleted: int, total: int}}
*/
private function syncSchemaToDatabase(string $schema_file, string $component_name): array
{
global $wpdb;
// Leer y decodificar JSON
$json_content = file_get_contents($schema_file);
if ($json_content === false) {
return [
'success' => false,
'message' => 'Error al leer archivo JSON',
'stats' => ['inserted' => 0, 'updated' => 0, 'deleted' => 0, 'total' => 0]
];
}
$schema = json_decode($json_content, true);
if ($schema === null) {
return [
'success' => false,
'message' => 'Error al decodificar JSON: ' . json_last_error_msg(),
'stats' => ['inserted' => 0, 'updated' => 0, 'deleted' => 0, 'total' => 0]
];
}
$table = $wpdb->prefix . 'roi_theme_component_settings';
$stats = ['inserted' => 0, 'updated' => 0, 'deleted' => 0, 'total' => 0];
// Iterar grupos y campos
if (!isset($schema['groups']) || !is_array($schema['groups'])) {
return [
'success' => false,
'message' => 'Schema inválido: falta clave "groups"',
'stats' => $stats
];
}
// Construir lista de campos válidos del schema
$validFields = [];
foreach ($schema['groups'] as $group_name => $group_data) {
if (!isset($group_data['fields']) || !is_array($group_data['fields'])) {
continue;
}
foreach ($group_data['fields'] as $attribute_name => $field_config) {
$validFields[] = $group_name . '::' . $attribute_name;
}
}
// PASO 1: Eliminar registros obsoletos (que no están en el schema)
$existing_records = $wpdb->get_results($wpdb->prepare(
"SELECT id, group_name, attribute_name FROM {$table} WHERE component_name = %s",
$component_name
));
foreach ($existing_records as $record) {
$key = $record->group_name . '::' . $record->attribute_name;
if (!in_array($key, $validFields, true)) {
$wpdb->delete($table, ['id' => $record->id], ['%d']);
$stats['deleted']++;
}
}
// PASO 2: Insertar/Actualizar campos del schema
foreach ($schema['groups'] as $group_name => $group_data) {
if (!isset($group_data['fields']) || !is_array($group_data['fields'])) {
continue;
}
foreach ($group_data['fields'] as $attribute_name => $field_config) {
$stats['total']++;
// Verificar si el campo ya existe
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT id, attribute_value FROM {$table}
WHERE component_name = %s
AND group_name = %s
AND attribute_name = %s",
$component_name,
$group_name,
$attribute_name
));
$is_editable = isset($field_config['editable']) ? (bool)$field_config['editable'] : false;
$default_value = isset($field_config['default']) ? $field_config['default'] : '';
// Convertir valor a string para almacenamiento
if (is_array($default_value) || is_object($default_value)) {
$default_value = json_encode($default_value);
} elseif (is_bool($default_value)) {
$default_value = $default_value ? '1' : '0';
} else {
$default_value = (string)$default_value;
}
if ($existing === null) {
// INSERT: Campo nuevo, usar default del JSON
$result = $wpdb->insert(
$table,
[
'component_name' => $component_name,
'group_name' => $group_name,
'attribute_name' => $attribute_name,
'attribute_value' => $default_value,
'is_editable' => $is_editable ? 1 : 0
],
['%s', '%s', '%s', '%s', '%d']
);
if ($result !== false) {
$stats['inserted']++;
}
} else {
// UPDATE: Campo existente, preservar valor del usuario, solo actualizar is_editable
$result = $wpdb->update(
$table,
['is_editable' => $is_editable ? 1 : 0],
[
'component_name' => $component_name,
'group_name' => $group_name,
'attribute_name' => $attribute_name
],
['%d'],
['%s', '%s', '%s']
);
if ($result !== false) {
$stats['updated']++;
}
}
}
}
return [
'success' => true,
'message' => "Componente '{$component_name}' sincronizado correctamente",
'stats' => $stats
];
}
}
// Registrar comando WP-CLI
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('roi-theme', MigrationCommand::class);
}

View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Di;
use ROITheme\Shared\Domain\Contracts\ComponentRepositoryInterface;
use ROITheme\Shared\Domain\Contracts\ComponentDefaultsRepositoryInterface;
use ROITheme\Shared\Domain\Contracts\ValidationServiceInterface;
use ROITheme\Shared\Domain\Contracts\CacheServiceInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Shared\Infrastructure\Persistence\Wordpress\WordPressComponentRepository;
use ROITheme\Shared\Infrastructure\Persistence\Wordpress\WordPressDefaultsRepository;
use ROITheme\Shared\Infrastructure\Persistence\Wordpress\WordPressComponentSettingsRepository;
use ROITheme\Shared\Infrastructure\Services\WordPressValidationService;
use ROITheme\Shared\Infrastructure\Services\WordPressCacheService;
use ROITheme\Shared\Infrastructure\Services\SchemaSyncService;
use ROITheme\Shared\Infrastructure\Services\CleanupService;
use ROITheme\Shared\Infrastructure\Services\CSSGeneratorService;
use ROITheme\Shared\Application\UseCases\GetComponentSettings\GetComponentSettingsUseCase;
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
/**
* DIContainer - Contenedor de Inyección de Dependencias
*
* RESPONSABILIDAD: Crear y gestionar instancias de servicios
*
* PATRÓN: Service Locator + Lazy Initialization
* - Lazy Initialization: Crear instancias solo cuando se necesitan
* - Singleton Pattern: Una sola instancia por servicio
* - Dependency Resolution: Resolver dependencias automáticamente
*
* USO:
* ```php
* $container = new DIContainer($wpdb, '/path/to/schemas');
* $repository = $container->getComponentRepository();
* $service = $container->getValidationService();
* ```
*
* @package ROITheme\Shared\Infrastructure\DI
*/
final class DIContainer
{
private array $instances = [];
public function __construct(
private \wpdb $wpdb,
private string $schemasPath
) {}
/**
* Obtener repositorio de componentes
*
* Lazy initialization: Crea la instancia solo en la primera llamada
*
* @return ComponentRepositoryInterface
*/
public function getComponentRepository(): ComponentRepositoryInterface
{
if (!isset($this->instances['componentRepository'])) {
$this->instances['componentRepository'] = new WordPressComponentRepository(
$this->wpdb
);
}
return $this->instances['componentRepository'];
}
/**
* Obtener repositorio de defaults
*
* Lazy initialization: Crea la instancia solo en la primera llamada
*
* @return ComponentDefaultsRepositoryInterface
*/
public function getDefaultsRepository(): ComponentDefaultsRepositoryInterface
{
if (!isset($this->instances['defaultsRepository'])) {
$this->instances['defaultsRepository'] = new WordPressDefaultsRepository(
$this->wpdb
);
}
return $this->instances['defaultsRepository'];
}
/**
* Obtener servicio de validación
*
* Lazy initialization: Crea la instancia solo en la primera llamada
* Resuelve dependencia: getDefaultsRepository()
*
* @return ValidationServiceInterface
*/
public function getValidationService(): ValidationServiceInterface
{
if (!isset($this->instances['validationService'])) {
$this->instances['validationService'] = new WordPressValidationService(
$this->getDefaultsRepository()
);
}
return $this->instances['validationService'];
}
/**
* Obtener servicio de cache
*
* Lazy initialization: Crea la instancia solo en la primera llamada
*
* @return CacheServiceInterface
*/
public function getCacheService(): CacheServiceInterface
{
if (!isset($this->instances['cacheService'])) {
$this->instances['cacheService'] = new WordPressCacheService(
$this->wpdb
);
}
return $this->instances['cacheService'];
}
/**
* Obtener servicio de sincronización de schemas
*
* Lazy initialization: Crea la instancia solo en la primera llamada
* Resuelve dependencia: getDefaultsRepository()
*
* @return SchemaSyncService
*/
public function getSchemaSyncService(): SchemaSyncService
{
if (!isset($this->instances['schemaSyncService'])) {
$this->instances['schemaSyncService'] = new SchemaSyncService(
$this->getDefaultsRepository(),
$this->schemasPath
);
}
return $this->instances['schemaSyncService'];
}
/**
* Obtener servicio de limpieza
*
* Lazy initialization: Crea la instancia solo en la primera llamada
* Resuelve dependencias: getComponentRepository(), getDefaultsRepository()
*
* @return CleanupService
*/
public function getCleanupService(): CleanupService
{
if (!isset($this->instances['cleanupService'])) {
$this->instances['cleanupService'] = new CleanupService(
$this->getComponentRepository(),
$this->getDefaultsRepository()
);
}
return $this->instances['cleanupService'];
}
/**
* Obtener servicio de generación de CSS
*
* Lazy initialization: Crea la instancia solo en la primera llamada
* Sin dependencias
*
* @return CSSGeneratorInterface
*/
public function getCSSGeneratorService(): CSSGeneratorInterface
{
if (!isset($this->instances['cssGeneratorService'])) {
$this->instances['cssGeneratorService'] = new CSSGeneratorService();
}
return $this->instances['cssGeneratorService'];
}
/**
* Obtener repositorio de configuraciones de componentes
*
* Lazy initialization: Crea la instancia solo en la primera llamada
*
* @return ComponentSettingsRepositoryInterface
*/
public function getComponentSettingsRepository(): ComponentSettingsRepositoryInterface
{
if (!isset($this->instances['componentSettingsRepository'])) {
$this->instances['componentSettingsRepository'] = new WordPressComponentSettingsRepository(
$this->wpdb
);
}
return $this->instances['componentSettingsRepository'];
}
/**
* Obtener caso de uso para obtener configuraciones de componentes
*
* Lazy initialization: Crea la instancia solo en la primera llamada
* Resuelve dependencia: getComponentSettingsRepository()
*
* @return GetComponentSettingsUseCase
*/
public function getGetComponentSettingsUseCase(): GetComponentSettingsUseCase
{
if (!isset($this->instances['getComponentSettingsUseCase'])) {
$this->instances['getComponentSettingsUseCase'] = new GetComponentSettingsUseCase(
$this->getComponentSettingsRepository()
);
}
return $this->instances['getComponentSettingsUseCase'];
}
/**
* Obtener caso de uso para guardar configuraciones de componentes
*
* Lazy initialization: Crea la instancia solo en la primera llamada
* Resuelve dependencia: getComponentSettingsRepository()
*
* @return SaveComponentSettingsUseCase
*/
public function getSaveComponentSettingsUseCase(): SaveComponentSettingsUseCase
{
if (!isset($this->instances['saveComponentSettingsUseCase'])) {
$this->instances['saveComponentSettingsUseCase'] = new SaveComponentSettingsUseCase(
$this->getComponentSettingsRepository()
);
}
return $this->instances['saveComponentSettingsUseCase'];
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Facades;
use ROITheme\Shared\Application\UseCases\SaveComponent\SaveComponentUseCase;
use ROITheme\Shared\Application\UseCases\SaveComponent\SaveComponentRequest;
use ROITheme\Shared\Application\UseCases\GetComponent\GetComponentUseCase;
use ROITheme\Shared\Application\UseCases\GetComponent\GetComponentRequest;
use ROITheme\Shared\Application\UseCases\DeleteComponent\DeleteComponentUseCase;
use ROITheme\Shared\Application\UseCases\DeleteComponent\DeleteComponentRequest;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
/**
* ComponentManager - Facade para el sistema
*
* RESPONSABILIDAD: Punto de entrada unificado y simple
*
* PATRÓN: Facade
* - Oculta complejidad interna
* - Proporciona API simple
* - Orquesta Use Cases
*
* USO:
* ```php
* $manager = new ComponentManager($container);
* $result = $manager->saveComponent('top_bar', $data);
* ```
*
* @package ROITheme\Infrastructure\Facades
*/
final class ComponentManager
{
public function __construct(
private DIContainer $container
) {}
/**
* Guardar componente
*
* @param string $componentName
* @param array $data
* @return array ['success' => bool, 'data' => mixed, 'errors' => array|null]
*/
public function saveComponent(string $componentName, array $data): array
{
$request = new SaveComponentRequest($componentName, $data);
$useCase = new SaveComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getValidationService(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
return $response->toArray();
}
/**
* Obtener componente
*
* @param string $componentName
* @return array ['success' => bool, 'data' => mixed, 'error' => string|null]
*/
public function getComponent(string $componentName): array
{
$request = new GetComponentRequest($componentName);
$useCase = new GetComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
return $response->toArray();
}
/**
* Eliminar componente
*
* @param string $componentName
* @return array ['success' => bool, 'message' => string|null, 'error' => string|null]
*/
public function deleteComponent(string $componentName): array
{
$request = new DeleteComponentRequest($componentName);
$useCase = new DeleteComponentUseCase(
$this->container->getComponentRepository(),
$this->container->getCacheService()
);
$response = $useCase->execute($request);
return [
'success' => $response->isSuccess(),
'message' => $response->getMessage(),
'error' => $response->getError()
];
}
/**
* Sincronizar schemas desde JSON
*
* @param string|null $componentName Si null, sincroniza todos
* @return array
*/
public function syncSchema(?string $componentName = null): array
{
$syncService = $this->container->getSchemaSyncService();
if ($componentName === null) {
return $syncService->syncAll();
}
return $syncService->syncComponent($componentName);
}
/**
* Limpiar componentes obsoletos
*
* @return array ['removed' => array]
*/
public function cleanup(): array
{
$cleanupService = $this->container->getCleanupService();
return $cleanupService->removeObsolete();
}
/**
* Invalidar todo el cache
*
* @return bool
*/
public function invalidateCache(): bool
{
return $this->container->getCacheService()->invalidateAll();
}
}

View File

@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Logging;
/**
* DeprecationLogger - Sistema de logging para código deprecated
*
* RESPONSABILIDAD: Registrar uso de código legacy para análisis y migración
*
* FEATURES:
* - Log en archivo de texto
* - Almacenamiento en base de datos para reportes
* - Niveles de severidad
* - Deduplicación de entradas
*
* @package ROITheme\Infrastructure\Logging
*/
final class DeprecationLogger
{
private static ?self $instance = null;
private string $logFile;
private \wpdb $wpdb;
private string $tableName;
private bool $enabled = true;
private function __construct()
{
global $wpdb;
$this->wpdb = $wpdb;
$this->tableName = $wpdb->prefix . 'roi_theme_deprecation_log';
$uploadDir = wp_upload_dir();
$logDir = $uploadDir['basedir'] . '/roi-theme-logs';
if (!file_exists($logDir)) {
wp_mkdir_p($logDir);
}
$this->logFile = $logDir . '/deprecation-' . date('Y-m-d') . '.log';
// Asegurar que la tabla existe
$this->ensureTableExists();
}
/**
* Get singleton instance
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Log deprecation use
*
* @param string $class Class name
* @param string $method Method name
* @param array $args Arguments passed
* @param string $replacement Suggested replacement
* @param string $version Version deprecated in
* @return void
*/
public function log(
string $class,
string $method,
array $args = [],
string $replacement = '',
string $version = '2.0.0'
): void {
if (!$this->enabled) {
return;
}
$logEntry = [
'timestamp' => current_time('mysql'),
'class' => $class,
'method' => $method,
'args' => json_encode($args),
'replacement' => $replacement,
'version' => $version,
'backtrace' => $this->getSimplifiedBacktrace(),
'url' => $this->getCurrentUrl(),
'user_id' => get_current_user_id()
];
// Log to file
$this->logToFile($logEntry);
// Log to database (with deduplication)
$this->logToDatabase($logEntry);
// Trigger action for external monitoring
do_action('roi_theme_deprecation_logged', $logEntry);
}
/**
* Get deprecation statistics
*
* @param int $days Number of days to analyze
* @return array Statistics
*/
public function getStatistics(int $days = 7): array
{
$since = date('Y-m-d H:i:s', strtotime("-{$days} days"));
$sql = $this->wpdb->prepare(
"SELECT
class,
method,
COUNT(*) as usage_count,
MAX(timestamp) as last_used,
replacement
FROM {$this->tableName}
WHERE timestamp >= %s
GROUP BY class, method, replacement
ORDER BY usage_count DESC",
$since
);
return $this->wpdb->get_results($sql, ARRAY_A) ?: [];
}
/**
* Get most used deprecated methods
*
* @param int $limit Number of results
* @return array Top deprecated methods
*/
public function getTopDeprecated(int $limit = 10): array
{
$sql = $this->wpdb->prepare(
"SELECT
class,
method,
COUNT(*) as usage_count,
replacement
FROM {$this->tableName}
GROUP BY class, method, replacement
ORDER BY usage_count DESC
LIMIT %d",
$limit
);
return $this->wpdb->get_results($sql, ARRAY_A) ?: [];
}
/**
* Clear old logs
*
* @param int $days Keep logs newer than this many days
* @return int Number of deleted entries
*/
public function clearOldLogs(int $days = 30): int
{
$since = date('Y-m-d H:i:s', strtotime("-{$days} days"));
$deleted = $this->wpdb->query(
$this->wpdb->prepare(
"DELETE FROM {$this->tableName} WHERE timestamp < %s",
$since
)
);
return (int) $deleted;
}
/**
* Enable/disable logging
*
* @param bool $enabled
*/
public function setEnabled(bool $enabled): void
{
$this->enabled = $enabled;
}
/**
* Log to file
*
* @param array $entry Log entry
*/
private function logToFile(array $entry): void
{
$message = sprintf(
"[%s] %s::%s() deprecated in v%s - Use %s instead\n",
$entry['timestamp'],
$entry['class'],
$entry['method'],
$entry['version'],
$entry['replacement'] ?: 'see documentation'
);
if (!empty($entry['backtrace'])) {
$message .= " Called from: {$entry['backtrace']}\n";
}
if (!empty($entry['url'])) {
$message .= " URL: {$entry['url']}\n";
}
$message .= " ---\n";
error_log($message, 3, $this->logFile);
}
/**
* Log to database with deduplication
*
* @param array $entry Log entry
*/
private function logToDatabase(array $entry): void
{
// Check if same call was logged in last hour (deduplication)
$hash = md5($entry['class'] . $entry['method'] . $entry['url']);
$oneHourAgo = date('Y-m-d H:i:s', strtotime('-1 hour'));
$exists = $this->wpdb->get_var(
$this->wpdb->prepare(
"SELECT id FROM {$this->tableName}
WHERE call_hash = %s
AND timestamp > %s
LIMIT 1",
$hash,
$oneHourAgo
)
);
if ($exists) {
// Update count instead of creating new entry
$this->wpdb->query(
$this->wpdb->prepare(
"UPDATE {$this->tableName}
SET usage_count = usage_count + 1,
timestamp = %s
WHERE id = %d",
$entry['timestamp'],
$exists
)
);
return;
}
// Insert new entry
$this->wpdb->insert(
$this->tableName,
[
'timestamp' => $entry['timestamp'],
'class' => $entry['class'],
'method' => $entry['method'],
'args' => $entry['args'],
'replacement' => $entry['replacement'],
'version' => $entry['version'],
'backtrace' => $entry['backtrace'],
'url' => $entry['url'],
'user_id' => $entry['user_id'],
'call_hash' => $hash,
'usage_count' => 1
],
['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d']
);
}
/**
* Get simplified backtrace
*
* @return string Simplified backtrace
*/
private function getSimplifiedBacktrace(): string
{
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
// Skip first 3 frames (this method, log method, deprecated function)
$relevant = array_slice($trace, 3, 2);
$parts = [];
foreach ($relevant as $frame) {
if (isset($frame['file']) && isset($frame['line'])) {
$file = basename($frame['file']);
$parts[] = "{$file}:{$frame['line']}";
}
}
return implode(' -> ', $parts);
}
/**
* Get current URL
*
* @return string Current URL
*/
private function getCurrentUrl(): string
{
if (!isset($_SERVER['REQUEST_URI'])) {
return '';
}
$scheme = is_ssl() ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$uri = $_SERVER['REQUEST_URI'] ?? '';
return $scheme . '://' . $host . $uri;
}
/**
* Ensure database table exists
*/
private function ensureTableExists(): void
{
$charset_collate = $this->wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$this->tableName} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
timestamp datetime NOT NULL,
class varchar(255) NOT NULL,
method varchar(255) NOT NULL,
args text,
replacement varchar(255) DEFAULT '',
version varchar(20) DEFAULT '2.0.0',
backtrace text,
url varchar(500) DEFAULT '',
user_id bigint(20) unsigned DEFAULT 0,
call_hash varchar(32) NOT NULL,
usage_count int(11) DEFAULT 1,
PRIMARY KEY (id),
KEY class_method (class, method),
KEY timestamp (timestamp),
KEY call_hash (call_hash)
) $charset_collate;";
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
dbDelta($sql);
}
/**
* Prevent cloning
*/
private function __clone() {}
/**
* Prevent unserialization
*/
public function __wakeup()
{
throw new \Exception('Cannot unserialize singleton');
}
}

View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Persistence\Wordpress;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\ValueObjects\ComponentName;
use ROITheme\Shared\Domain\ValueObjects\ComponentConfiguration;
use ROITheme\Shared\Domain\ValueObjects\ComponentVisibility;
use ROITheme\Shared\Domain\Contracts\ComponentRepositoryInterface;
use ROITheme\Shared\Domain\Exceptions\ComponentNotFoundException;
/**
* WordPressComponentRepository - Implementación con WordPress/MySQL
*
* RESPONSABILIDAD: Persistir y recuperar componentes desde wp_roi_theme_components
*
* DEPENDENCIAS EXTERNAS:
* - wpdb (WordPress database abstraction)
* - Tabla wp_roi_theme_components (creada en Fase 2)
*
* CONVERSIÓN:
* - Entity → Array → MySQL (al guardar)
* - MySQL → Array → Entity (al recuperar)
*
* @package ROITheme\Infrastructure\Persistence\WordPress
*/
final class WordPressComponentRepository implements ComponentRepositoryInterface
{
private string $tableName;
/**
* @param \wpdb $wpdb WordPress database object
*/
public function __construct(
private \wpdb $wpdb
) {
$this->tableName = $this->wpdb->prefix . 'roi_theme_components';
}
/**
* Guardar componente
*
* INSERT si no existe, UPDATE si existe
*
* @param Component $component
* @return Component Componente guardado (con timestamps actualizados)
*/
public function save(Component $component): Component
{
$componentName = $component->name()->value();
// Verificar si ya existe
$existing = $this->findByName($componentName);
$data = [
'component_name' => $componentName,
'configuration' => json_encode($component->configuration()->toArray()),
'visibility' => json_encode($component->visibility()->toArray()),
'is_enabled' => $component->isEnabled() ? 1 : 0,
'schema_version' => $component->schemaVersion(),
'updated_at' => current_time('mysql')
];
if ($existing === null) {
// INSERT
$data['created_at'] = current_time('mysql');
$this->wpdb->insert(
$this->tableName,
$data,
['%s', '%s', '%s', '%d', '%s', '%s', '%s']
);
} else {
// UPDATE
$this->wpdb->update(
$this->tableName,
$data,
['component_name' => $componentName],
['%s', '%s', '%s', '%d', '%s', '%s'],
['%s']
);
}
// Return the saved component by fetching it from the database
return $this->getByName($component->name());
}
/**
* Buscar componente por nombre
*
* @param ComponentName $name Nombre del componente
* @return Component|null Null si no existe
*/
public function findByName(ComponentName $name): ?Component
{
$sql = $this->wpdb->prepare(
"SELECT * FROM {$this->tableName} WHERE component_name = %s LIMIT 1",
$name->value()
);
$row = $this->wpdb->get_row($sql, ARRAY_A);
if ($row === null) {
return null;
}
return $this->rowToEntity($row);
}
/**
* Obtener componente por nombre (lanza excepción si no existe)
*
* @param ComponentName $name
* @return Component
* @throws ComponentNotFoundException
*/
public function getByName(ComponentName $name): Component
{
$component = $this->findByName($name);
if ($component === null) {
throw ComponentNotFoundException::withName($name->value());
}
return $component;
}
/**
* Obtener todos los componentes
*
* @return Component[]
*/
public function findAll(): array
{
$sql = "SELECT * FROM {$this->tableName} ORDER BY component_name ASC";
$rows = $this->wpdb->get_results($sql, ARRAY_A);
if (empty($rows)) {
return [];
}
return array_map(
fn($row) => $this->rowToEntity($row),
$rows
);
}
/**
* Eliminar componente
*
* @param ComponentName $name Nombre del componente
* @return bool True si eliminó exitosamente
*/
public function delete(ComponentName $name): bool
{
$result = $this->wpdb->delete(
$this->tableName,
['component_name' => $name->value()],
['%s']
);
return $result !== false;
}
/**
* Obtener componentes habilitados
*
* @return Component[]
*/
public function findEnabled(): array
{
$sql = "SELECT * FROM {$this->tableName} WHERE is_enabled = 1 ORDER BY component_name ASC";
$rows = $this->wpdb->get_results($sql, ARRAY_A);
if (empty($rows)) {
return [];
}
return array_map(
fn($row) => $this->rowToEntity($row),
$rows
);
}
/**
* Verificar si existe un componente con el nombre dado
*
* @param ComponentName $name
* @return bool
*/
public function exists(ComponentName $name): bool
{
return $this->findByName($name) !== null;
}
/**
* Obtener cantidad total de componentes
*
* @return int
*/
public function count(): int
{
$sql = "SELECT COUNT(*) FROM {$this->tableName}";
return (int) $this->wpdb->get_var($sql);
}
/**
* Obtener componentes por grupo de configuración
*
* @param string $group Grupo de configuración (visibility, content, styles, general)
* @return Component[]
*/
public function findByConfigGroup(string $group): array
{
// For now, return all components as we don't have a specific column for groups
// This would require additional schema design
return $this->findAll();
}
/**
* Convertir fila de BD a Entity
*
* @param array $row Fila de la base de datos
* @return Component
*/
private function rowToEntity(array $row): Component
{
// Decodificar JSON
$configuration = json_decode($row['configuration'], true) ?? [];
$visibility = json_decode($row['visibility'], true) ?? [];
// Crear Value Objects
$name = new ComponentName($row['component_name']);
$config = ComponentConfiguration::fromArray($configuration);
$vis = ComponentVisibility::fromArray($visibility);
// Crear Entity
return new Component(
$name,
$config,
$vis,
(bool) $row['is_enabled'],
$row['schema_version']
);
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Persistence\Wordpress;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
/**
* Implementaci<63>n de repositorio de configuraciones usando WordPress/MySQL
*
* Infrastructure Layer - WordPress specific
* Trabaja con la tabla wp_roi_theme_component_settings
*
* @package ROITheme\Shared\Infrastructure\Persistence\WordPress
*/
final class WordPressComponentSettingsRepository implements ComponentSettingsRepositoryInterface
{
private string $tableName;
public function __construct(
private \wpdb $wpdb
) {
$this->tableName = $this->wpdb->prefix . 'roi_theme_component_settings';
}
/**
* {@inheritDoc}
*/
public function getComponentSettings(string $componentName): array
{
$sql = $this->wpdb->prepare(
"SELECT group_name, attribute_name, attribute_value
FROM {$this->tableName}
WHERE component_name = %s
ORDER BY group_name, attribute_name",
$componentName
);
$rows = $this->wpdb->get_results($sql, ARRAY_A);
if (empty($rows)) {
return [];
}
// Agrupar por grupo
$settings = [];
foreach ($rows as $row) {
$groupName = $row['group_name'];
$attributeName = $row['attribute_name'];
$value = $row['attribute_value'];
// Convertir valores seg<65>n tipo
$value = $this->unserializeValue($value);
if (!isset($settings[$groupName])) {
$settings[$groupName] = [];
}
$settings[$groupName][$attributeName] = $value;
}
return $settings;
}
/**
* {@inheritDoc}
*/
public function saveComponentSettings(string $componentName, array $settings): int
{
$updated = 0;
foreach ($settings as $groupName => $attributes) {
foreach ($attributes as $attributeName => $value) {
if ($this->saveFieldValue($componentName, $groupName, $attributeName, $value)) {
$updated++;
}
}
}
return $updated;
}
/**
* {@inheritDoc}
*/
public function getFieldValue(string $componentName, string $groupName, string $attributeName): mixed
{
$sql = $this->wpdb->prepare(
"SELECT attribute_value
FROM {$this->tableName}
WHERE component_name = %s
AND group_name = %s
AND attribute_name = %s
LIMIT 1",
$componentName,
$groupName,
$attributeName
);
$value = $this->wpdb->get_var($sql);
if ($value === null) {
return null;
}
return $this->unserializeValue($value);
}
/**
* {@inheritDoc}
*/
public function saveFieldValue(string $componentName, string $groupName, string $attributeName, mixed $value): bool
{
// Serializar valor
$serializedValue = $this->serializeValue($value);
// Intentar actualizar
$result = $this->wpdb->update(
$this->tableName,
['attribute_value' => $serializedValue],
[
'component_name' => $componentName,
'group_name' => $groupName,
'attribute_name' => $attributeName
],
['%s'],
['%s', '%s', '%s']
);
return $result !== false;
}
/**
* {@inheritDoc}
*/
public function resetToDefaults(string $componentName, string $schemaPath): int
{
if (!file_exists($schemaPath)) {
return 0;
}
$schema = json_decode(file_get_contents($schemaPath), true);
if (!$schema || !isset($schema['groups'])) {
return 0;
}
$updated = 0;
// Iterar grupos y campos del schema
foreach ($schema['groups'] as $groupName => $group) {
foreach ($group['fields'] as $fieldName => $field) {
$defaultValue = $field['default'] ?? '';
if ($this->saveFieldValue($componentName, $groupName, $fieldName, $defaultValue)) {
$updated++;
}
}
}
return $updated;
}
/**
* Serializa un valor para guardarlo en la BD
*
* @param mixed $value
* @return string
*/
private function serializeValue(mixed $value): string
{
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_array($value)) {
return json_encode($value);
}
return (string) $value;
}
/**
* Deserializa un valor desde la BD
*
* @param string $value
* @return mixed
*/
private function unserializeValue(string $value): mixed
{
// Intentar decodificar JSON
if (str_starts_with($value, '{') || str_starts_with($value, '[')) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
}
// Convertir booleanos
if ($value === '1' || $value === '0') {
return $value === '1';
}
// Devolver como est<73>
return $value;
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Persistence\Wordpress;
use ROITheme\Shared\Domain\Contracts\ComponentDefaultsRepositoryInterface;
use ROITheme\Shared\Domain\ValueObjects\ComponentName;
use ROITheme\Shared\Domain\ValueObjects\ComponentConfiguration;
/**
* WordPressDefaultsRepository - Defaults/Schemas desde MySQL
*
* RESPONSABILIDAD: Gestionar schemas de componentes (estructura, validaciones, defaults)
*
* TABLA: wp_roi_theme_defaults
*
* @package ROITheme\Infrastructure\Persistence\WordPress
*/
final class WordPressDefaultsRepository implements ComponentDefaultsRepositoryInterface
{
private string $tableName;
public function __construct(
private \wpdb $wpdb
) {
$this->tableName = $this->wpdb->prefix . 'roi_theme_defaults';
}
/**
* Obtener configuración por defecto de un componente
*
* @param ComponentName $name Nombre del componente
* @return ComponentConfiguration Configuración por defecto
*/
public function getByName(ComponentName $name): ComponentConfiguration
{
$sql = $this->wpdb->prepare(
"SELECT default_schema FROM {$this->tableName} WHERE component_name = %s LIMIT 1",
$name->value()
);
$result = $this->wpdb->get_var($sql);
if ($result === null) {
// Return empty configuration if no defaults found
return ComponentConfiguration::fromArray([]);
}
$schema = json_decode($result, true) ?? [];
return ComponentConfiguration::fromArray($schema);
}
/**
* Guardar configuración por defecto para un componente
*
* @param ComponentName $name Nombre del componente
* @param ComponentConfiguration $configuration Configuración por defecto
* @return void
*/
public function save(ComponentName $name, ComponentConfiguration $configuration): void
{
$existing = $this->exists($name);
$data = [
'component_name' => $name->value(),
'default_schema' => json_encode($configuration->all()),
'updated_at' => current_time('mysql')
];
if (!$existing) {
// INSERT
$data['created_at'] = current_time('mysql');
$this->wpdb->insert(
$this->tableName,
$data,
['%s', '%s', '%s', '%s']
);
} else {
// UPDATE
$this->wpdb->update(
$this->tableName,
$data,
['component_name' => $name->value()],
['%s', '%s', '%s'],
['%s']
);
}
}
/**
* Verificar si existen defaults para un componente
*
* @param ComponentName $name
* @return bool
*/
public function exists(ComponentName $name): bool
{
$sql = $this->wpdb->prepare(
"SELECT COUNT(*) FROM {$this->tableName} WHERE component_name = %s",
$name->value()
);
return (int) $this->wpdb->get_var($sql) > 0;
}
/**
* Obtener todos los defaults
*
* @return array<string, ComponentConfiguration> Array asociativo nombre => configuración
*/
public function findAll(): array
{
$sql = "SELECT component_name, default_schema FROM {$this->tableName}";
$rows = $this->wpdb->get_results($sql, ARRAY_A);
$defaults = [];
foreach ($rows as $row) {
$schema = json_decode($row['default_schema'], true) ?? [];
$defaults[$row['component_name']] = ComponentConfiguration::fromArray($schema);
}
return $defaults;
}
/**
* Eliminar defaults de un componente
*
* @param ComponentName $name
* @return bool True si se eliminó, false si no existía
*/
public function delete(ComponentName $name): bool
{
$result = $this->wpdb->delete(
$this->tableName,
['component_name' => $name->value()],
['%s']
);
return $result !== false && $result > 0;
}
/**
* Obtener schema/defaults de un componente (método legacy)
*
* @deprecated Use getByName() instead
* @param string $componentName
* @return array|null Schema del componente o null si no existe
*/
public function find(string $componentName): ?array
{
$name = new ComponentName($componentName);
if (!$this->exists($name)) {
return null;
}
$config = $this->getByName($name);
return $config->all();
}
/**
* Guardar schema/defaults de un componente (método legacy)
*
* @deprecated Use save() instead
* @param string $componentName
* @param array $schema
* @return bool
*/
public function saveDefaults(string $componentName, array $schema): bool
{
$name = new ComponentName($componentName);
$config = ComponentConfiguration::fromArray($schema);
$this->save($name, $config);
return true;
}
/**
* Actualizar schema existente (método legacy)
*
* @deprecated Use save() instead
* @param string $componentName
* @param array $schema
* @return bool
*/
public function updateDefaults(string $componentName, array $schema): bool
{
return $this->saveDefaults($componentName, $schema);
}
/**
* Eliminar defaults de un componente (método legacy)
*
* @deprecated Use delete() instead
* @param string $componentName
* @return bool
*/
public function deleteDefaults(string $componentName): bool
{
$name = new ComponentName($componentName);
return $this->delete($name);
}
}

View File

@@ -0,0 +1,133 @@
# Capa de Infraestructura
## 📋 Propósito
La **Capa de Infraestructura** contiene las implementaciones concretas de las interfaces definidas en el Dominio. Conecta la lógica de negocio con el mundo exterior (WordPress, MySQL, cache, HTTP).
## 🎯 Responsabilidades
- ✅ Implementar interfaces del Dominio
- ✅ Conectar con frameworks (WordPress)
- ✅ Persistencia (MySQL via wpdb)
- ✅ Cache (WordPress Transients)
- ✅ HTTP (AJAX endpoints)
- ✅ Contiene detalles de implementación
## 📦 Estructura
```
Infrastructure/
├── Persistence/
│ └── WordPress/
│ ├── WordPressComponentRepository.php (MySQL)
│ └── WordPressDefaultsRepository.php (Schemas)
├── Services/
│ ├── WordPressValidationService.php (Validación)
│ ├── WordPressCacheService.php (Transients)
│ ├── SchemaSyncService.php (JSON → BD)
│ └── CleanupService.php (Limpieza)
├── API/
│ └── WordPress/
│ └── AjaxController.php (Endpoints AJAX)
├── Facades/
│ └── ComponentManager.php (API unificada)
├── DI/
│ └── DIContainer.php (Dependency Injection)
└── README.md
```
## 🔌 Implementaciones
### Repositories
#### WordPressComponentRepository
Persiste componentes en `wp_roi_theme_components`.
**Métodos**:
- `save(Component)`: INSERT o UPDATE
- `findByName(string)`: Buscar por nombre
- `findAll()`: Obtener todos
- `delete(string)`: Eliminar
#### WordPressDefaultsRepository
Gestiona schemas en `wp_roi_theme_defaults`.
### Services
#### WordPressValidationService
Valida datos contra schemas.
**Estrategia**:
1. Obtener schema de BD
2. Validar estructura
3. Sanitizar con funciones WordPress
#### WordPressCacheService
Cache con WordPress Transients API.
**TTL default**: 1 hora (3600 segundos)
#### SchemaSyncService
Sincroniza schemas desde JSON a BD.
#### CleanupService
Elimina componentes obsoletos.
### API
#### AjaxController
Endpoints AJAX de WordPress.
**Endpoints disponibles**:
- `roi_theme_save_component` (POST)
- `roi_theme_get_component` (GET)
- `roi_theme_delete_component` (POST)
- `roi_theme_sync_schema` (POST)
**Seguridad**:
- Nonce verification
- Capability check (manage_options)
### Facade
#### ComponentManager
API unificada para todo el sistema.
**Uso**:
```php
$manager = new ComponentManager($container);
$result = $manager->saveComponent('top_bar', $data);
```
## 📐 Principios Arquitectónicos
### Dependency Inversion
Infrastructure implementa interfaces del Domain:
```php
class WordPressComponentRepository implements ComponentRepositoryInterface
{
// Implementación específica de WordPress
}
```
### Separación de Concerns
- **Repositories**: Solo persistencia
- **Services**: Lógica de infraestructura
- **Controller**: Solo HTTP
- **Facade**: Orquestación simple
## 🧪 Testing
Tests de **integración** (usan BD real de WordPress):
```bash
vendor\bin\phpunit tests\Integration\Infrastructure
```
## 🔗 Referencias
- Domain Layer: `../Domain/README.md`
- Application Layer: `../Application/README.md`

View File

@@ -0,0 +1,210 @@
<?php
/**
* Script de validación de arquitectura ROI Theme
*
* Valida que un componente cumple con:
* - Clean Architecture (estructura de carpetas)
* - Fase 01: Schema JSON
* - Fase 02: Sincronización JSON→BD
* - Fase 03: Renderers (DB→HTML/CSS)
* - Fase 04: FormBuilders (UI Admin)
*
* Uso:
* cd wp-content/themes/roi-theme
* php shared/Infrastructure/Scripts/validate-architecture.php <nombre-componente>
*
* Ejemplos:
* php shared/Infrastructure/Scripts/validate-architecture.php top-notification-bar
* php shared/Infrastructure/Scripts/validate-architecture.php navbar
* php shared/Infrastructure/Scripts/validate-architecture.php hero-section
*
* Códigos de salida:
* 0 = Validación exitosa (sin errores)
* 1 = Validación fallida (errores encontrados)
*/
declare(strict_types=1);
// Validar argumentos
if ($argc < 2) {
echo "❌ Error: Falta nombre del componente\n\n";
echo "Uso: php shared/Infrastructure/Scripts/validate-architecture.php <nombre-componente>\n\n";
echo "Ejemplos:\n";
echo " php shared/Infrastructure/Scripts/validate-architecture.php top-notification-bar\n";
echo " php shared/Infrastructure/Scripts/validate-architecture.php navbar\n";
exit(1);
}
$componentName = $argv[1];
// Determinar ruta al tema (3 niveles arriba desde Scripts/)
$themePath = dirname(__DIR__, 3);
// Cargar WordPress
$wpLoadPath = dirname($themePath, 3) . '/wp-load.php';
if (!file_exists($wpLoadPath)) {
echo "❌ Error: No se pudo cargar WordPress\n";
echo "Ruta esperada: {$wpLoadPath}\n";
echo "Asegúrate de ejecutar el script desde el directorio del tema\n";
exit(1);
}
require_once $wpLoadPath;
// Cargar validadores manualmente
$validatorsPath = __DIR__ . '/../Validators/';
require_once $validatorsPath . 'ValidationResult.php';
require_once $validatorsPath . 'PhaseValidatorInterface.php';
require_once $validatorsPath . 'FolderStructureValidator.php';
require_once $validatorsPath . 'Phase01Validator.php';
require_once $validatorsPath . 'Phase02Validator.php';
require_once $validatorsPath . 'Phase03Validator.php';
require_once $validatorsPath . 'Phase04Validator.php';
require_once $validatorsPath . 'Phase05Validator.php';
require_once $validatorsPath . 'CSSConflictValidator.php';
require_once $validatorsPath . 'TemplateCallsValidator.php';
use ROITheme\Shared\Infrastructure\Validators\FolderStructureValidator;
use ROITheme\Shared\Infrastructure\Validators\Phase01Validator;
use ROITheme\Shared\Infrastructure\Validators\Phase02Validator;
use ROITheme\Shared\Infrastructure\Validators\Phase03Validator;
use ROITheme\Shared\Infrastructure\Validators\Phase04Validator;
use ROITheme\Shared\Infrastructure\Validators\Phase05Validator;
use ROITheme\Shared\Infrastructure\Validators\CSSConflictValidator;
use ROITheme\Shared\Infrastructure\Validators\TemplateCallsValidator;
// Header
printHeader($componentName);
// Crear validadores
$validators = [
new FolderStructureValidator(), // Estructura de carpetas (base)
new Phase01Validator(), // Schema JSON
new Phase02Validator(), // JSON→BD Sync
new Phase03Validator(), // Renderers
new Phase04Validator(), // FormBuilders
new Phase05Validator(), // General SOLID (todos los archivos)
new CSSConflictValidator(), // Conflictos CSS (Assets vs Renderers dinámicos)
new TemplateCallsValidator(), // Llamadas roi_render_component() en templates
];
// Ejecutar validaciones
$allSuccess = true;
$totalErrors = 0;
$totalWarnings = 0;
foreach ($validators as $validator) {
$result = $validator->validate($componentName, $themePath);
// Imprimir resultado
printValidationResult($validator, $result);
if (!$result->isSuccess()) {
$allSuccess = false;
}
$totalErrors += $result->getErrorCount();
$totalWarnings += $result->getWarningCount();
echo "\n";
}
// Resumen final
printSummary($allSuccess, $totalErrors, $totalWarnings);
// Código de salida
exit($allSuccess ? 0 : 1);
/**
* Imprime el header del script
*/
function printHeader(string $componentName): void
{
echo "\n";
echo "╔═══════════════════════════════════════════════════════════╗\n";
echo "║ ARCHITECTURE VALIDATOR - ROI THEME ║\n";
echo "╚═══════════════════════════════════════════════════════════╝\n";
echo "\n";
echo "Validando componente: {$componentName}\n";
echo "\n";
}
/**
* Imprime el resultado de una validación
*/
function printValidationResult($validator, $result): void
{
echo "═══════════════════════════════════════════════════════════\n";
echo "FASE {$validator->getPhaseNumber()}: {$validator->getPhaseDescription()}\n";
echo "═══════════════════════════════════════════════════════════\n";
// Info messages
if (!empty($result->getInfo())) {
foreach ($result->getInfo() as $info) {
echo " {$info}\n";
}
}
// Stats
if (!empty($result->getStats())) {
echo "\n📊 Estadísticas:\n";
foreach ($result->getStats() as $key => $value) {
echo "{$key}: {$value}\n";
}
}
// Warnings
if (!empty($result->getWarnings())) {
echo "\n⚠️ ADVERTENCIAS:\n";
foreach ($result->getWarnings() as $warning) {
echo "{$warning}\n";
}
}
// Errors
if (!empty($result->getErrors())) {
echo "\n❌ ERRORES CRÍTICOS:\n";
foreach ($result->getErrors() as $error) {
echo "{$error}\n";
}
}
// Result
echo "\n";
if ($result->isSuccess()) {
echo "✅ FASE {$validator->getPhaseNumber()}: EXITOSA\n";
} else {
echo "❌ FASE {$validator->getPhaseNumber()}: FALLIDA ({$result->getErrorCount()} errores)\n";
}
}
/**
* Imprime el resumen final
*/
function printSummary(bool $allSuccess, int $totalErrors, int $totalWarnings): void
{
echo "\n";
echo "╔═══════════════════════════════════════════════════════════╗\n";
echo "║ RESUMEN FINAL ║\n";
echo "╚═══════════════════════════════════════════════════════════╝\n";
echo "\n";
echo "Total errores: {$totalErrors}\n";
echo "Total advertencias: {$totalWarnings}\n";
echo "\n";
if ($allSuccess) {
echo "✅ ¡VALIDACIÓN EXITOSA!\n";
echo "El componente cumple con todos los estándares arquitectónicos.\n";
} else {
echo "❌ VALIDACIÓN FALLIDA\n";
echo "Corregir errores críticos antes de continuar.\n";
echo "\n";
echo "Acciones recomendadas:\n";
echo " • Revisar errores arriba\n";
echo " • Corregir violaciones arquitectónicas\n";
echo " • Ejecutar validación nuevamente\n";
}
echo "\n";
}

View File

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
/**
* Class CSSGeneratorService
*
* Implementación concreta del generador de CSS.
* Convierte arrays de configuración de estilos en reglas CSS válidas y formateadas.
*
* Responsabilidades:
* - Generar string CSS a partir de selector y estilos
* - Convertir propiedades snake_case → kebab-case
* - Normalizar nombres de propiedades (text_color → color)
* - Formatear reglas CSS con indentación legible
* - Sanitizar valores para prevenir inyección
*
* @package ROITheme\Shared\Infrastructure\Services
*/
final class CSSGeneratorService implements CSSGeneratorInterface
{
/**
* Mapa de nombres de propiedades CSS normalizadas.
*
* @var array<string, string>
*/
private const PROPERTY_MAP = [
'text-color' => 'color',
'bg-color' => 'background-color',
];
/**
* {@inheritDoc}
*/
public function generate(string $selector, array $styles): string
{
if (empty($styles)) {
return '';
}
// Filtrar valores vacíos o null
$styles = $this->filterEmptyValues($styles);
if (empty($styles)) {
return '';
}
// Convertir array de estilos a propiedades CSS
$cssProperties = $this->buildCSSProperties($styles);
// Formatear regla CSS completa
return $this->formatCSSRule($selector, $cssProperties);
}
/**
* Filtra valores vacíos, null o que solo contienen espacios en blanco.
*
* @param array<string, mixed> $styles Array de estilos
* @return array<string, string> Array filtrado
*/
private function filterEmptyValues(array $styles): array
{
return array_filter(
$styles,
fn($value) => $value !== null && $value !== '' && trim((string)$value) !== ''
);
}
/**
* Convierte array de estilos a propiedades CSS formateadas.
*
* @param array<string, string> $styles Array de estilos
* @return array<int, string> Array de propiedades CSS formateadas
*/
private function buildCSSProperties(array $styles): array
{
$properties = [];
foreach ($styles as $property => $value) {
// Convertir snake_case a kebab-case
$cssProperty = $this->convertToKebabCase($property);
// Normalizar nombre de propiedad
$cssProperty = $this->normalizePropertyName($cssProperty);
// Sanitizar valor
$sanitizedValue = $this->sanitizeValue((string)$value);
// Agregar propiedad formateada
$properties[] = sprintf('%s: %s;', $cssProperty, $sanitizedValue);
}
return $properties;
}
/**
* Convierte snake_case a kebab-case.
*
* Ejemplos:
* - background_color → background-color
* - font_size → font-size
* - padding_top → padding-top
*
* @param string $property Nombre de propiedad en snake_case
* @return string Nombre de propiedad en kebab-case
*/
private function convertToKebabCase(string $property): string
{
return str_replace('_', '-', strtolower($property));
}
/**
* Normaliza nombres de propiedades CSS a su forma estándar.
*
* Mapea alias comunes a nombres de propiedades CSS estándar:
* - text-color → color
* - bg-color → background-color
*
* @param string $property Nombre de propiedad
* @return string Nombre de propiedad normalizado
*/
private function normalizePropertyName(string $property): string
{
return self::PROPERTY_MAP[$property] ?? $property;
}
/**
* Sanitiza valores CSS para prevenir inyección de código.
*
* Remueve tags HTML y caracteres potencialmente peligrosos,
* manteniendo valores CSS válidos como colores, unidades, etc.
*
* @param string $value Valor CSS sin sanitizar
* @return string Valor CSS sanitizado
*/
private function sanitizeValue(string $value): string
{
// Remover tags HTML
$value = strip_tags($value);
// Remover caracteres de control excepto espacios
$value = preg_replace('/[^\P{C}\s]/u', '', $value);
// Trim espacios
$value = trim($value);
return $value;
}
/**
* Formatea la regla CSS completa con selector y propiedades.
*
* Genera CSS con formato legible:
* ```css
* .selector {
* property: value;
* property2: value2;
* }
* ```
*
* @param string $selector Selector CSS
* @param array<int, string> $properties Array de propiedades formateadas
* @return string Regla CSS completa
*/
private function formatCSSRule(string $selector, array $properties): string
{
if (empty($properties)) {
return '';
}
return sprintf(
"%s {\n %s\n}",
$selector,
implode("\n ", $properties)
);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Component\Infrastructure\Persistence\Wordpress\WordPressComponentRepository;
use ROITheme\Component\Infrastructure\Persistence\Wordpress\WordPressDefaultsRepository;
/**
* CleanupService - Limpieza de componentes obsoletos
*
* RESPONSABILIDAD: Eliminar componentes que ya no existen en schema
*
* @package ROITheme\Infrastructure\Services
*/
final class CleanupService
{
public function __construct(
private WordPressComponentRepository $componentRepository,
private WordPressDefaultsRepository $defaultsRepository
) {}
/**
* Eliminar componentes que no tienen schema
*
* @return array ['removed' => array]
*/
public function removeObsolete(): array
{
// Obtener todos los componentes actuales
$components = $this->componentRepository->findAll();
// Obtener schemas disponibles
$schemas = $this->defaultsRepository->findAll();
$validNames = array_keys($schemas);
$removed = [];
foreach ($components as $component) {
$name = $component->name()->value();
// Si el componente no tiene schema, es obsoleto
if (!in_array($name, $validNames)) {
$this->componentRepository->delete($name);
$removed[] = $name;
}
}
return ['removed' => $removed];
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Infrastructure\Persistence\Wordpress\WordPressDefaultsRepository;
/**
* SchemaSyncService - Sincronizar schemas JSON → BD
*
* RESPONSABILIDAD: Leer schemas desde archivos JSON y sincronizar con BD
*
* FLUJO:
* 1. Leer archivos JSON de schemas
* 2. Comparar con BD actual
* 3. Agregar/Actualizar/Eliminar según diferencias
*
* @package ROITheme\Infrastructure\Services
*/
final class SchemaSyncService
{
private string $schemasPath;
public function __construct(
private WordPressDefaultsRepository $defaultsRepository,
string $schemasPath
) {
$this->schemasPath = rtrim($schemasPath, '/');
}
/**
* Sincronizar todos los schemas
*
* @return array ['success' => bool, 'data' => array]
*/
public function syncAll(): array
{
try {
// 1. Leer schemas desde JSON
$schemas = $this->readSchemasFromJson();
if (empty($schemas)) {
return [
'success' => false,
'error' => 'No schemas found in JSON files'
];
}
// 2. Obtener schemas actuales de BD
$currentSchemas = $this->defaultsRepository->findAll();
// 3. Determinar cambios
$schemaNames = array_keys($schemas);
$currentNames = array_keys($currentSchemas);
$toAdd = array_diff($schemaNames, $currentNames);
$toUpdate = array_intersect($schemaNames, $currentNames);
$toDelete = array_diff($currentNames, $schemaNames);
// 4. Aplicar cambios
$added = [];
$updated = [];
$deleted = [];
foreach ($toAdd as $name) {
$this->defaultsRepository->saveDefaults($name, $schemas[$name]);
$added[] = $name;
}
foreach ($toUpdate as $name) {
$this->defaultsRepository->updateDefaults($name, $schemas[$name]);
$updated[] = $name;
}
foreach ($toDelete as $name) {
$this->defaultsRepository->deleteDefaults($name);
$deleted[] = $name;
}
return [
'success' => true,
'data' => [
'added' => $added,
'updated' => $updated,
'deleted' => $deleted
]
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
/**
* Sincronizar un componente específico
*
* @param string $componentName
* @return array
*/
public function syncComponent(string $componentName): array
{
try {
$schemas = $this->readSchemasFromJson();
if (!isset($schemas[$componentName])) {
return [
'success' => false,
'error' => "Schema not found for: {$componentName}"
];
}
$this->defaultsRepository->saveDefaults($componentName, $schemas[$componentName]);
return [
'success' => true,
'data' => ['synced' => $componentName]
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
/**
* Leer schemas desde archivos JSON
*
* @return array Array asociativo [componentName => schema]
*/
private function readSchemasFromJson(): array
{
$schemas = [];
// Escanear directorio de schemas
$files = glob($this->schemasPath . '/*.json');
if (empty($files)) {
throw new \RuntimeException("No schema files found in: {$this->schemasPath}");
}
foreach ($files as $file) {
$content = file_get_contents($file);
$schema = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON in file ' . basename($file) . ': ' . json_last_error_msg());
}
if (!isset($schema['component_name'])) {
throw new \RuntimeException('Missing component_name in schema file: ' . basename($file));
}
$componentName = $schema['component_name'];
$schemas[$componentName] = $schema;
}
return $schemas;
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\CacheServiceInterface;
/**
* WordPressCacheService - Cache con Transients API
*
* RESPONSABILIDAD: Gestionar cache de componentes
*
* IMPLEMENTACIÓN: WordPress Transients
* - get_transient()
* - set_transient()
* - delete_transient()
*
* VENTAJAS:
* - Compatible con object cache (Redis, Memcached)
* - Expiración automática
* - API simple
*
* @package ROITheme\Infrastructure\Services
*/
final class WordPressCacheService implements CacheServiceInterface
{
private const PREFIX = 'roi_theme_';
public function __construct(
private \wpdb $wpdb
) {}
/**
* Obtener valor del cache
*
* @param string $key Clave del cache
* @return mixed|null Valor o null si no existe/expiró
*/
public function get(string $key): mixed
{
$transient = get_transient($this->getFullKey($key));
// WordPress devuelve false si no existe
return $transient === false ? null : $transient;
}
/**
* Guardar valor en cache
*
* @param string $key Clave del cache
* @param mixed $value Valor a guardar
* @param int $expiration Tiempo de vida en segundos (default 1 hora)
* @return bool True si guardó exitosamente
*/
public function set(string $key, mixed $value, int $expiration = 3600): bool
{
return set_transient($this->getFullKey($key), $value, $expiration);
}
/**
* Eliminar entrada de cache
*
* @param string $key Clave del cache
* @return bool True si eliminó exitosamente
*/
public function delete(string $key): bool
{
return $this->invalidate($key);
}
/**
* Limpiar todo el cache
*
* @return bool
*/
public function flush(): bool
{
return $this->invalidateAll();
}
/**
* Invalidar (eliminar) entrada de cache
*
* @param string $key Clave del cache
* @return bool True si eliminó exitosamente
*/
public function invalidate(string $key): bool
{
return delete_transient($this->getFullKey($key));
}
/**
* Invalidar todo el cache de componentes
*
* @return bool
*/
public function invalidateAll(): bool
{
// Obtener todos los componentes
$components = $this->wpdb->get_col(
"SELECT DISTINCT component_name FROM {$this->wpdb->prefix}roi_theme_components"
);
$success = true;
foreach ($components as $componentName) {
$result = $this->invalidate("component_{$componentName}");
$success = $success && $result;
}
return $success;
}
/**
* Obtener clave completa con prefijo
*
* @param string $key
* @return string
*/
private function getFullKey(string $key): string
{
return self::PREFIX . $key;
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\ValidationServiceInterface;
use ROITheme\Shared\Domain\ValidationResult;
use ROITheme\Component\Infrastructure\Persistence\Wordpress\WordPressDefaultsRepository;
/**
* WordPressValidationService - Validación contra schemas
*
* RESPONSABILIDAD: Validar y sanitizar datos de componentes
*
* ESTRATEGIA:
* 1. Obtener schema del componente desde BD
* 2. Validar estructura contra schema
* 3. Sanitizar datos usando funciones de WordPress
*
* @package ROITheme\Infrastructure\Services
*/
final class WordPressValidationService implements ValidationServiceInterface
{
public function __construct(
private WordPressDefaultsRepository $defaultsRepository
) {}
/**
* Validar datos contra schema
*
* @param array $data Datos a validar
* @param string $componentName Nombre del componente (para obtener schema)
* @return ValidationResult
*/
public function validate(array $data, string $componentName): ValidationResult
{
// 1. Obtener schema
$schema = $this->defaultsRepository->find($componentName);
if ($schema === null) {
return ValidationResult::failure([
"Schema not found for component: {$componentName}"
]);
}
// 2. Sanitizar datos primero
$sanitized = $this->sanitize($data, $componentName);
// 3. Validar estructura
$errors = [];
foreach ($sanitized as $groupName => $fields) {
// Verificar que el grupo existe en schema
if (!isset($schema[$groupName])) {
$errors[$groupName] = "Unknown group: {$groupName}";
continue;
}
// Validar cada campo del grupo
if (is_array($fields)) {
foreach ($fields as $key => $value) {
if (!isset($schema[$groupName][$key])) {
$errors["{$groupName}.{$key}"] = "Unknown field: {$groupName}.{$key}";
}
// Validaciones adicionales pueden agregarse aquí
// Por ejemplo, validar tipos, rangos, formatos, etc.
}
}
}
if (!empty($errors)) {
return ValidationResult::failure($errors);
}
return ValidationResult::success($sanitized);
}
/**
* Sanitizar datos recursivamente
*
* Usa funciones de WordPress según el tipo de dato
*
* @param array $data Datos a sanitizar
* @param string $componentName Nombre del componente
* @return array Datos sanitizados
*/
public function sanitize(array $data, string $componentName): array
{
$sanitized = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
// Recursivo para arrays anidados
$sanitized[$key] = $this->sanitizeValue($value);
} else {
$sanitized[$key] = $this->sanitizeValue($value);
}
}
return $sanitized;
}
/**
* Sanitizar un valor individual
*
* @param mixed $value
* @return mixed
*/
private function sanitizeValue(mixed $value): mixed
{
if (is_array($value)) {
$sanitized = [];
foreach ($value as $k => $v) {
$sanitized[$k] = $this->sanitizeValue($v);
}
return $sanitized;
}
if (is_bool($value)) {
return (bool) $value;
}
if (is_numeric($value)) {
return is_float($value) ? (float) $value : (int) $value;
}
if (is_string($value) && filter_var($value, FILTER_VALIDATE_URL)) {
return esc_url_raw($value);
}
if (is_string($value)) {
return sanitize_text_field($value);
}
return $value;
}
/**
* Validar una URL
*
* @param string $url
* @return bool
*/
public function isValidUrl(string $url): bool
{
return filter_var($url, FILTER_VALIDATE_URL) !== false;
}
/**
* Validar un color hexadecimal
*
* @param string $color
* @return bool
*/
public function isValidColor(string $color): bool
{
return preg_match('/^#[0-9A-F]{6}$/i', $color) === 1;
}
/**
* Validar nombre de componente
*
* @param string $name
* @return bool
*/
public function isValidComponentName(string $name): bool
{
// Solo letras minúsculas, números y guiones bajos
return preg_match('/^[a-z0-9_]+$/', $name) === 1;
}
}

View File

View File

@@ -0,0 +1,697 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\UI;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\FormBuilderInterface;
/**
* TopNotificationBarFormBuilder - Construye formulario de configuración
*
* RESPONSABILIDAD: Generar formulario HTML del admin para Top Notification Bar
*
* CARACTERÍSTICAS:
* - 3 secciones: Visibilidad, Contenido, Estilos
* - 19 campos configurables
* - Lógica condicional (data-conditional-field)
* - WordPress Media Library integration
* - Vista previa en tiempo real
*
* @package ROITheme\Shared\Infrastructure\UI
*/
final class TopNotificationBarFormBuilder implements FormBuilderInterface
{
public function build(Component $component): string
{
$data = $component->getData();
$componentId = $component->getName();
$html = '<div class="roi-form-builder roi-top-notification-bar-form">';
// Sección de Visibilidad
$html .= $this->buildVisibilitySection($data, $componentId);
// Sección de Contenido
$html .= $this->buildContentSection($data, $componentId);
// Sección de Estilos
$html .= $this->buildStylesSection($data, $componentId);
// Vista previa
$html .= $this->buildPreviewSection($data);
$html .= '</div>';
// Agregar scripts de formulario
$html .= $this->buildFormScripts($componentId);
return $html;
}
private function buildVisibilitySection(array $data, string $componentId): string
{
$html = '<div class="roi-form-section" data-section="visibility">';
$html .= '<h3 class="roi-form-section-title">Visibilidad</h3>';
$html .= '<div class="roi-form-section-content">';
// Is Enabled
$isEnabled = $data['visibility']['is_enabled'] ?? true;
$html .= $this->buildToggle(
'is_enabled',
'Mostrar barra de notificación',
$isEnabled,
$componentId,
'Activa o desactiva la barra de notificación superior'
);
// Show On Pages
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
$html .= $this->buildSelect(
'show_on_pages',
'Mostrar en',
$showOn,
[
'all' => 'Todas las páginas',
'home' => 'Solo página de inicio',
'posts' => 'Solo posts individuales',
'pages' => 'Solo páginas',
'custom' => 'Páginas específicas'
],
$componentId,
'Define en qué páginas se mostrará la barra'
);
// Custom Page IDs
$customPageIds = $data['visibility']['custom_page_ids'] ?? '';
$html .= $this->buildTextField(
'custom_page_ids',
'IDs de páginas específicas',
$customPageIds,
$componentId,
'IDs de páginas separados por comas',
'Ej: 1,5,10',
['data-conditional-field' => 'show_on_pages', 'data-conditional-value' => 'custom']
);
// Hide On Mobile
$hideOnMobile = $data['visibility']['hide_on_mobile'] ?? false;
$html .= $this->buildToggle(
'hide_on_mobile',
'Ocultar en dispositivos móviles',
$hideOnMobile,
$componentId,
'Oculta la barra en pantallas menores a 768px'
);
// Is Dismissible
$isDismissible = $data['visibility']['is_dismissible'] ?? false;
$html .= $this->buildToggle(
'is_dismissible',
'Permitir cerrar',
$isDismissible,
$componentId,
'Agrega botón X para que el usuario pueda cerrar la barra'
);
// Dismissible Cookie Days
$cookieDays = $data['visibility']['dismissible_cookie_days'] ?? 7;
$html .= $this->buildNumberField(
'dismissible_cookie_days',
'Días antes de volver a mostrar',
$cookieDays,
$componentId,
'Días que permanece oculta después de cerrarla',
1,
365,
['data-conditional-field' => 'is_dismissible', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildContentSection(array $data, string $componentId): string
{
$html = '<div class="roi-form-section" data-section="content">';
$html .= '<h3 class="roi-form-section-title">Contenido</h3>';
$html .= '<div class="roi-form-section-content">';
// Icon Type
$iconType = $data['content']['icon_type'] ?? 'bootstrap';
$html .= $this->buildSelect(
'icon_type',
'Tipo de ícono',
$iconType,
[
'bootstrap' => 'Bootstrap Icons',
'custom' => 'Imagen personalizada',
'none' => 'Sin ícono'
],
$componentId,
'Selecciona el tipo de ícono a mostrar'
);
// Bootstrap Icon
$bootstrapIcon = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill';
$html .= $this->buildTextField(
'bootstrap_icon',
'Clase de ícono Bootstrap',
$bootstrapIcon,
$componentId,
'Nombre de la clase del ícono sin el prefijo \'bi\' (ej: megaphone-fill)',
'Ej: bi-megaphone-fill',
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'bootstrap']
);
// Custom Icon URL
$customIconUrl = $data['content']['custom_icon_url'] ?? '';
$html .= $this->buildMediaField(
'custom_icon_url',
'Imagen personalizada',
$customIconUrl,
$componentId,
'Sube una imagen personalizada (recomendado: PNG 24x24px)',
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'custom']
);
// Announcement Label
$announcementLabel = $data['content']['announcement_label'] ?? 'Nuevo:';
$html .= $this->buildTextField(
'announcement_label',
'Etiqueta del anuncio',
$announcementLabel,
$componentId,
'Texto destacado en negrita antes del mensaje',
'Ej: Nuevo:, Importante:, Aviso:'
);
// Announcement Text
$announcementText = $data['content']['announcement_text'] ?? 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.';
$html .= $this->buildTextArea(
'announcement_text',
'Texto del anuncio',
$announcementText,
$componentId,
'Mensaje principal del anuncio (máximo 200 caracteres)',
3
);
// Link Enabled
$linkEnabled = $data['content']['link_enabled'] ?? true;
$html .= $this->buildToggle(
'link_enabled',
'Mostrar enlace',
$linkEnabled,
$componentId,
'Activa o desactiva el enlace de acción'
);
// Link Text
$linkText = $data['content']['link_text'] ?? 'Ver Catálogo';
$html .= $this->buildTextField(
'link_text',
'Texto del enlace',
$linkText,
$componentId,
'Texto del enlace de acción',
'',
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
);
// Link URL
$linkUrl = $data['content']['link_url'] ?? '#';
$html .= $this->buildUrlField(
'link_url',
'URL del enlace',
$linkUrl,
$componentId,
'URL de destino del enlace',
'https://',
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
);
// Link Target
$linkTarget = $data['content']['link_target'] ?? '_self';
$html .= $this->buildSelect(
'link_target',
'Abrir enlace en',
$linkTarget,
[
'_self' => 'Misma ventana',
'_blank' => 'Nueva ventana'
],
$componentId,
'Define cómo se abrirá el enlace',
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildStylesSection(array $data, string $componentId): string
{
$html = '<div class="roi-form-section" data-section="styles">';
$html .= '<h3 class="roi-form-section-title">Estilos</h3>';
$html .= '<div class="roi-form-section-content">';
// Background Color
$bgColor = $data['styles']['background_color'] ?? '#FF8600';
$html .= $this->buildColorField(
'background_color',
'Color de fondo',
$bgColor,
$componentId,
'Color de fondo de la barra (por defecto: orange primary)'
);
// Text Color
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
$html .= $this->buildColorField(
'text_color',
'Color del texto',
$textColor,
$componentId,
'Color del texto del anuncio'
);
// Link Color
$linkColor = $data['styles']['link_color'] ?? '#FFFFFF';
$html .= $this->buildColorField(
'link_color',
'Color del enlace',
$linkColor,
$componentId,
'Color del enlace de acción'
);
// Font Size
$fontSize = $data['styles']['font_size'] ?? 'small';
$html .= $this->buildSelect(
'font_size',
'Tamaño de fuente',
$fontSize,
[
'extra-small' => 'Muy pequeño (0.75rem)',
'small' => 'Pequeño (0.875rem)',
'normal' => 'Normal (1rem)',
'large' => 'Grande (1.125rem)'
],
$componentId,
'Tamaño del texto del anuncio'
);
// Padding Vertical
$padding = $data['styles']['padding_vertical'] ?? 'normal';
$html .= $this->buildSelect(
'padding_vertical',
'Padding vertical',
$padding,
[
'compact' => 'Compacto (0.5rem)',
'normal' => 'Normal (0.75rem)',
'spacious' => 'Espacioso (1rem)'
],
$componentId,
'Espaciado vertical interno de la barra'
);
// Text Alignment
$alignment = $data['styles']['text_alignment'] ?? 'center';
$html .= $this->buildSelect(
'text_alignment',
'Alineación del texto',
$alignment,
[
'left' => 'Izquierda',
'center' => 'Centro',
'right' => 'Derecha'
],
$componentId,
'Alineación del contenido de la barra'
);
// Animation Enabled
$animationEnabled = $data['styles']['animation_enabled'] ?? false;
$html .= $this->buildToggle(
'animation_enabled',
'Activar animación',
$animationEnabled,
$componentId,
'Activa animación de entrada al cargar la página'
);
// Animation Type
$animationType = $data['styles']['animation_type'] ?? 'slide-down';
$html .= $this->buildSelect(
'animation_type',
'Tipo de animación',
$animationType,
[
'slide-down' => 'Deslizar desde arriba',
'fade-in' => 'Aparecer gradualmente'
],
$componentId,
'Tipo de animación de entrada',
['data-conditional-field' => 'animation_enabled', 'data-conditional-value' => 'true']
);
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildPreviewSection(array $data): string
{
$html = '<div class="roi-form-section roi-preview-section">';
$html .= '<h3 class="roi-form-section-title">Vista Previa</h3>';
$html .= '<div class="roi-form-section-content">';
$html .= '<div id="roi-component-preview" class="border rounded p-3 bg-light">';
$html .= '<p class="text-muted">La vista previa se actualizará automáticamente al modificar los campos.</p>';
$html .= '</div>';
$html .= '</div>';
$html .= '</div>';
return $html;
}
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $description = ''): string
{
$fieldId = "roi_{$componentId}_{$name}";
$checked = $value ? 'checked' : '';
$html = '<div class="roi-form-field roi-form-field-toggle mb-3">';
$html .= '<div class="form-check form-switch">';
$html .= sprintf(
'<input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s]" value="1" %s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
$checked
);
$html .= sprintf('<label class="form-check-label" for="%s">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '</div>';
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildTextField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-text mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" placeholder="%s"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value),
esc_attr($placeholder),
$attrString
);
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildTextArea(string $name, string $label, string $value, string $componentId, string $description = '', int $rows = 3, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-textarea mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<textarea class="form-control" id="%s" name="roi_component[%s][%s]" rows="%d"%s>%s</textarea>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
$rows,
$attrString,
esc_textarea($value)
);
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $description = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-select mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<select class="form-select" id="%s" name="roi_component[%s][%s]"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
$attrString
);
foreach ($options as $optValue => $optLabel) {
$selected = ($value === $optValue) ? 'selected' : '';
$html .= sprintf(
'<option value="%s" %s>%s</option>',
esc_attr($optValue),
$selected,
esc_html($optLabel)
);
}
$html .= '</select>';
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildNumberField(string $name, string $label, $value, string $componentId, string $description = '', int $min = null, int $max = null, array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-number mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$attrs['type'] = 'number';
if ($min !== null) {
$attrs['min'] = $min;
}
if ($max !== null) {
$attrs['max'] = $max;
}
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<input class="form-control" id="%s" name="roi_component[%s][%s]" value="%s"%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value),
$attrString
);
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildUrlField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
{
$attrs['type'] = 'url';
return $this->buildTextField($name, $label, $value, $componentId, $description, $placeholder, $attrs);
}
private function buildColorField(string $name, string $label, string $value, string $componentId, string $description = ''): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-color mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '<div class="input-group">';
$html .= sprintf(
'<input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s]" value="%s">',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value)
);
$html .= sprintf(
'<input type="text" class="form-control" value="%s" readonly>',
esc_attr($value)
);
$html .= '</div>';
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $description = '', array $attrs = []): string
{
$fieldId = "roi_{$componentId}_{$name}";
$html = '<div class="roi-form-field roi-form-field-media mb-3">';
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
$html .= '<div class="input-group">';
$attrString = $this->buildAttributesString($attrs);
$html .= sprintf(
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" readonly%s>',
esc_attr($fieldId),
esc_attr($componentId),
esc_attr($name),
esc_attr($value),
$attrString
);
$html .= sprintf(
'<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>',
esc_attr($fieldId)
);
$html .= '</div>';
if (!empty($value)) {
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 100px; height: auto;"></div>', esc_url($value));
}
if (!empty($description)) {
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
}
$html .= '</div>';
return $html;
}
private function buildAttributesString(array $attrs): string
{
$attrString = '';
foreach ($attrs as $key => $value) {
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
}
return $attrString;
}
private function buildFormScripts(string $componentId): string
{
return <<<SCRIPT
<script>
(function($) {
'use strict';
$(document).ready(function() {
// Conditional logic
$('[data-conditional-field]').each(function() {
const field = $(this);
const targetFieldName = field.data('conditional-field');
const targetValue = field.data('conditional-value');
const targetField = $('[name*="[' + targetFieldName + ']"]');
function updateVisibility() {
let currentValue;
if (targetField.is(':checkbox')) {
currentValue = targetField.is(':checked') ? 'true' : 'false';
} else {
currentValue = targetField.val();
}
if (currentValue === targetValue) {
field.closest('.roi-form-field').show();
} else {
field.closest('.roi-form-field').hide();
}
}
targetField.on('change', updateVisibility);
updateVisibility();
});
// Media upload
$('.roi-media-upload-btn').on('click', function(e) {
e.preventDefault();
const button = $(this);
const targetId = button.data('target');
const targetField = $('#' + targetId);
const mediaUploader = wp.media({
title: 'Seleccionar imagen',
button: { text: 'Usar esta imagen' },
multiple: false
});
mediaUploader.on('select', function() {
const attachment = mediaUploader.state().get('selection').first().toJSON();
targetField.val(attachment.url);
const preview = targetField.closest('.roi-form-field-media').find('img');
if (preview.length) {
preview.attr('src', attachment.url);
} else {
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 100px; height: auto;"></div>');
}
});
mediaUploader.open();
});
// Color picker sync
$('.form-control-color').on('change', function() {
$(this).next('input[type="text"]').val($(this).val());
});
// Auto-update preview
$('.roi-form-field input, .roi-form-field select, .roi-form-field textarea').on('change keyup', function() {
updatePreview();
});
function updatePreview() {
// Aquí iría la lógica para actualizar la vista previa en tiempo real
console.log('Preview updated');
}
});
})(jQuery);
</script>
SCRIPT;
}
public function supports(string $componentType): bool
{
return $componentType === 'top-notification-bar';
}
}

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Conflictos CSS
*
* Detecta archivos CSS en Assets/ que podrían sobrescribir
* estilos generados dinámicamente por Renderers.
*
* Valida que:
* - NO existan archivos CSS hardcodeados para componentes con Renderer dinámico
* - NO haya reglas !important que sobrescriban estilos dinámicos
* - NO se encolen CSS externos que conflictúen con Renderers
*
* @since 1.0.0
*/
final class CSSConflictValidator implements PhaseValidatorInterface
{
/**
* Mapeo de componentes con Renderer dinámico a sus posibles archivos CSS conflictivos
*/
private const DYNAMIC_RENDERER_COMPONENTS = [
'top-notification-bar' => [
'css_files' => ['componente-top-bar.css', 'top-notification-bar.css'],
'css_classes' => ['.top-notification-bar', '.top-bar'],
],
'navbar' => [
'css_files' => ['componente-navbar.css', 'navbar.css'],
'css_classes' => ['.navbar', '.nav-link', '.navbar-brand', '.dropdown-menu'],
],
'cta-lets-talk' => [
'css_files' => ['componente-boton-lets-talk.css', 'cta-lets-talk.css'],
'css_classes' => ['.btn-lets-talk', '.cta-lets-talk'],
],
];
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validando conflictos CSS para: {$componentName}");
// Solo validar componentes con Renderer dinámico conocidos
if (!isset(self::DYNAMIC_RENDERER_COMPONENTS[$componentName])) {
$result->addInfo("Componente no tiene Renderer dinámico registrado - Omitiendo validación CSS");
return $result;
}
$componentConfig = self::DYNAMIC_RENDERER_COMPONENTS[$componentName];
// 1. Verificar que existe un Renderer dinámico
$hasRenderer = $this->hasRendererFile($componentName, $themePath);
if (!$hasRenderer) {
$result->addInfo("No se encontró Renderer dinámico - Validación CSS no aplica");
return $result;
}
$result->addInfo("✓ Renderer dinámico detectado");
// 2. Buscar archivos CSS conflictivos en Assets/css/
$cssData = $this->validateAssetsCSSFiles($componentName, $componentConfig, $themePath, $result);
// 3. Validar enqueue-scripts.php y determinar errores vs warnings
$this->validateEnqueueScripts($componentName, $componentConfig, $themePath, $result, $cssData);
return $result;
}
/**
* Verifica si existe un Renderer para el componente
*/
private function hasRendererFile(string $componentName, string $themePath): bool
{
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
$publicPath = $themePath . '/Public/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'Renderer.php';
if (file_exists($publicPath)) {
return true;
}
$adminPath = $themePath . '/Admin/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'Renderer.php';
if (file_exists($adminPath)) {
return true;
}
return false;
}
/**
* Valida archivos CSS en Assets/css/
*
* @return array{files: string[], important: array<string, int>} Archivos encontrados y violaciones
*/
private function validateAssetsCSSFiles(
string $componentName,
array $componentConfig,
string $themePath,
ValidationResult $result
): array {
$assetsPath = $themePath . '/Assets/css';
$cssFilesFound = [];
$importantViolations = [];
if (!is_dir($assetsPath)) {
$result->addInfo("No existe carpeta Assets/css/");
return ['files' => [], 'important' => []];
}
foreach ($componentConfig['css_files'] as $cssFileName) {
$cssFilePath = $assetsPath . '/' . $cssFileName;
if (file_exists($cssFilePath)) {
$cssFilesFound[] = $cssFileName;
$content = file_get_contents($cssFilePath);
// Buscar reglas !important
$importantCount = $this->countImportantRules($content);
if ($importantCount > 0) {
$importantViolations[$cssFileName] = $importantCount;
}
// Buscar clases CSS del componente
$conflictingClasses = $this->findConflictingClasses($content, $componentConfig['css_classes']);
if (!empty($conflictingClasses)) {
$result->addWarning(
"Archivo '{$cssFileName}' define clases del componente: " .
implode(', ', $conflictingClasses)
);
}
}
}
// Reportar archivos encontrados
if (!empty($cssFilesFound)) {
$result->addWarning(
"Archivos CSS hardcodeados encontrados en Assets/css/: " .
implode(', ', $cssFilesFound)
);
$result->addInfo(
"⚠️ Estos archivos podrían sobrescribir estilos generados dinámicamente por el Renderer"
);
} else {
$result->addInfo("✓ Sin archivos CSS hardcodeados conflictivos en Assets/css/");
}
// Stats
$result->setStat('Archivos CSS conflictivos', count($cssFilesFound));
$result->setStat('Reglas !important', array_sum($importantViolations));
return ['files' => $cssFilesFound, 'important' => $importantViolations];
}
/**
* Cuenta reglas !important en contenido CSS
*/
private function countImportantRules(string $content): int
{
preg_match_all('/!important/i', $content, $matches);
return count($matches[0]);
}
/**
* Encuentra clases CSS conflictivas en el contenido
*/
private function findConflictingClasses(string $content, array $cssClasses): array
{
$found = [];
foreach ($cssClasses as $className) {
// Escapar el punto para regex
$pattern = '/' . preg_quote($className, '/') . '\s*[{,]/';
if (preg_match($pattern, $content)) {
$found[] = $className;
}
}
return $found;
}
/**
* Valida Inc/enqueue-scripts.php para detectar CSS encolados
*
* @param array{files: string[], important: array<string, int>} $cssData Datos de archivos CSS encontrados
*/
private function validateEnqueueScripts(
string $componentName,
array $componentConfig,
string $themePath,
ValidationResult $result,
array $cssData
): void {
$enqueueScriptsPath = $themePath . '/Inc/enqueue-scripts.php';
if (!file_exists($enqueueScriptsPath)) {
$result->addWarning("No se encontró Inc/enqueue-scripts.php");
return;
}
$content = file_get_contents($enqueueScriptsPath);
$enqueuedFiles = [];
$commentedFiles = [];
foreach ($componentConfig['css_files'] as $cssFileName) {
// Buscar si el archivo está siendo encolado (multiline - wp_enqueue_style puede tener saltos de línea)
$pattern = '/wp_enqueue_style\s*\([^;]*' . preg_quote($cssFileName, '/') . '[^;]*\);/s';
if (preg_match($pattern, $content, $match)) {
// Verificar si está comentado
$matchPos = strpos($content, $match[0]);
$lineStart = strrpos(substr($content, 0, $matchPos), "\n") + 1;
$lineContent = substr($content, $lineStart, $matchPos - $lineStart);
// Verificar si la línea está en un bloque comentado /* */
$beforeMatch = substr($content, 0, $matchPos);
$lastCommentOpen = strrpos($beforeMatch, '/*');
$lastCommentClose = strrpos($beforeMatch, '*/');
$isCommented = false;
if ($lastCommentOpen !== false) {
if ($lastCommentClose === false || $lastCommentOpen > $lastCommentClose) {
$isCommented = true;
}
}
// También verificar comentario de línea //
if (strpos($lineContent, '//') !== false) {
$isCommented = true;
}
if ($isCommented) {
$commentedFiles[] = $cssFileName;
} else {
$enqueuedFiles[] = $cssFileName;
}
}
}
// Reportar resultados de enqueue
if (!empty($enqueuedFiles)) {
$result->addError(
"❌ CRÍTICO: CSS conflictivo ACTIVO en enqueue-scripts.php: " .
implode(', ', $enqueuedFiles)
);
$result->addInfo(
"SOLUCIÓN: Comentar wp_enqueue_style() para estos archivos ya que el Renderer genera CSS dinámico"
);
}
if (!empty($commentedFiles)) {
$result->addInfo(
"✓ CSS correctamente deshabilitado en enqueue-scripts.php: " .
implode(', ', $commentedFiles)
);
}
if (empty($enqueuedFiles) && empty($commentedFiles)) {
$result->addInfo("✓ Sin conflictos de enqueue detectados");
}
// Reportar !important violations basado en estado de enqueue
$importantViolations = $cssData['important'] ?? [];
if (!empty($importantViolations)) {
foreach ($importantViolations as $file => $count) {
if (in_array($file, $enqueuedFiles, true)) {
// CSS activo con !important → ERROR CRÍTICO
$result->addError(
"❌ CRÍTICO: '{$file}' ACTIVO con {$count} regla(s) !important que SOBRESCRIBEN estilos dinámicos"
);
} elseif (in_array($file, $commentedFiles, true)) {
// CSS deshabilitado con !important → WARNING (considerar eliminar archivo)
$result->addWarning(
"'{$file}' deshabilitado pero tiene {$count} regla(s) !important - Considerar eliminar archivo"
);
} else {
// Archivo existe pero no está en enqueue-scripts.php → WARNING
$result->addWarning(
"'{$file}' no está en enqueue-scripts.php pero tiene {$count} regla(s) !important"
);
}
}
}
}
public function getPhaseNumber(): int|string
{
return 'css';
}
public function getPhaseDescription(): string
{
return 'CSS Conflicts (Assets vs Dynamic Renderers)';
}
}

View File

@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Estructura de Carpetas Clean Architecture (PSR-4 PascalCase)
*
* Valida que:
* - Solo existen contextos permitidos (Admin/, Public/, Shared/)
* - Nombres de módulos en PascalCase (PSR-4 standard)
* - Shared Kernel: Infrastructure/ obligatoria, Domain/Application/ a nivel contexto
* - Carpetas dentro de capas son solo las arquitectónicamente permitidas
* - NO hay carpetas inventadas (Helpers/, Utils/, Lib/, etc.)
* - Niveles de profundidad correctos
*/
final class FolderStructureValidator implements PhaseValidatorInterface
{
private const ALLOWED_CONTEXTS = ['Admin', 'Public', 'Shared'];
private const REQUIRED_LAYERS = ['Domain', 'Application', 'Infrastructure'];
private const ALLOWED_IN_DOMAIN = ['Contracts', 'ValueObjects', 'Exceptions'];
private const ALLOWED_IN_APPLICATION = ['UseCases', 'Dtos', 'Contracts', 'Services'];
private const ALLOWED_IN_INFRASTRUCTURE = ['Persistence', 'Api', 'Ui', 'Services', 'WordPress', 'Traits', 'Di', 'Scripts', 'Validators'];
private const FORBIDDEN_NAMES = ['Helpers', 'Utils', 'Utilities', 'Lib', 'Libs', 'Core', 'Common', 'Base', 'Models'];
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validando estructura de carpetas para: {$componentName}");
// Buscar el módulo en Admin/ o Public/
$modulePath = $this->findModulePath($componentName, $themePath);
if ($modulePath === null) {
$result->addError("Módulo no encontrado en Admin/ ni Public/");
return $result;
}
$context = basename(dirname($modulePath));
$result->addInfo("Módulo encontrado en: {$context}/{$componentName}/");
// Validar nombre del módulo (debe coincidir con PascalCase esperado)
$this->validateModuleName($componentName, $modulePath, $result);
// Validar capas obligatorias
$this->validateRequiredLayers($modulePath, $result);
// Validar contenido de cada capa
$this->validateLayerContents($modulePath, $result);
// Validar profundidad de carpetas
$this->validateDepth($modulePath, $result);
// Buscar carpetas prohibidas
$this->findForbiddenFolders($modulePath, $result);
return $result;
}
private function findModulePath(string $componentName, string $themePath): ?string
{
// Convertir kebab-case a PascalCase (PSR-4 standard)
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
// Intentar en Public/ con PascalCase
$publicPath = $themePath . '/Public/' . $pascalCaseName;
if (is_dir($publicPath)) {
return $publicPath;
}
// Intentar en Admin/ con PascalCase
$adminPath = $themePath . '/Admin/' . $pascalCaseName;
if (is_dir($adminPath)) {
return $adminPath;
}
return null;
}
private function validateModuleName(string $componentName, string $modulePath, ValidationResult $result): void
{
// Convertir a PascalCase esperado
$pascalCaseExpected = str_replace('-', '', ucwords($componentName, '-'));
$actualFolderName = basename($modulePath);
// El nombre de la carpeta debe ser PascalCase (PSR-4 standard)
if ($actualFolderName !== $pascalCaseExpected) {
$result->addError("Nombre de carpeta '{$actualFolderName}' no coincide con PascalCase esperado '{$pascalCaseExpected}'");
} else {
$result->addInfo("✓ Nombre de carpeta en PascalCase: {$actualFolderName}");
}
}
private function validateRequiredLayers(string $modulePath, ValidationResult $result): void
{
// SHARED KERNEL PATTERN:
// Los módulos individuales solo requieren Infrastructure/
// Domain/ y Application/ están a nivel de contexto (Admin/, Public/)
// Infrastructure/ es OBLIGATORIA
$infrastructurePath = $modulePath . '/Infrastructure';
if (!is_dir($infrastructurePath)) {
$result->addError("Falta capa obligatoria: Infrastructure/");
} else {
$result->addInfo("✓ Capa Infrastructure/ existe");
}
// Domain/ y Application/ son OPCIONALES (Shared Kernel a nivel contexto)
foreach (['Domain', 'Application'] as $optionalLayer) {
$layerPath = $modulePath . '/' . $optionalLayer;
if (is_dir($layerPath)) {
$result->addInfo("✓ Capa {$optionalLayer}/ existe (específica del módulo)");
}
}
$result->addInfo(" Arquitectura Shared Kernel: Domain/ y Application/ a nivel contexto (Admin/, Public/)");
}
private function validateLayerContents(string $modulePath, ValidationResult $result): void
{
// Validar Domain/
$this->validateLayerContent(
$modulePath . '/Domain',
'Domain',
self::ALLOWED_IN_DOMAIN,
$result
);
// Validar Application/
$this->validateLayerContent(
$modulePath . '/Application',
'Application',
self::ALLOWED_IN_APPLICATION,
$result
);
// Validar Infrastructure/
$this->validateLayerContent(
$modulePath . '/Infrastructure',
'Infrastructure',
self::ALLOWED_IN_INFRASTRUCTURE,
$result
);
}
private function validateLayerContent(string $layerPath, string $layerName, array $allowedFolders, ValidationResult $result): void
{
if (!is_dir($layerPath)) {
return; // Ya reportado en validateRequiredLayers
}
$items = scandir($layerPath);
$folders = array_filter($items, function($item) use ($layerPath) {
return $item !== '.' && $item !== '..' && is_dir($layerPath . '/' . $item);
});
foreach ($folders as $folder) {
// Validar que la carpeta está en la lista permitida (PascalCase)
if (!in_array($folder, $allowedFolders, true)) {
$result->addError("Carpeta NO permitida en {$layerName}/: '{$folder}' (permitidas: " . implode(', ', $allowedFolders) . ")");
}
}
// Validaciones especiales para infrastructure/ui/
if ($layerName === 'infrastructure' && in_array('ui', $folders, true)) {
$this->validateUIStructure($layerPath . '/ui', $result);
}
// Validaciones especiales para infrastructure/api/
if ($layerName === 'infrastructure' && in_array('api', $folders, true)) {
$this->validateAPIStructure($layerPath . '/api', $result);
}
}
private function validateUIStructure(string $uiPath, ValidationResult $result): void
{
if (!is_dir($uiPath)) {
return;
}
$items = scandir($uiPath);
$folders = array_filter($items, function($item) use ($uiPath) {
return $item !== '.' && $item !== '..' && is_dir($uiPath . '/' . $item);
});
$allowedInUI = ['assets', 'templates', 'views'];
foreach ($folders as $folder) {
if (!in_array($folder, $allowedInUI, true)) {
$result->addWarning("Carpeta en ui/: '{$folder}' (esperadas: " . implode(', ', $allowedInUI) . ")");
}
}
// Si existe assets/, validar que tiene css/ y/o js/
if (in_array('assets', $folders, true)) {
$assetsPath = $uiPath . '/assets';
$assetsItems = scandir($assetsPath);
$assetsFolders = array_filter($assetsItems, function($item) use ($assetsPath) {
return $item !== '.' && $item !== '..' && is_dir($assetsPath . '/' . $item);
});
$allowedInAssets = ['css', 'js', 'images', 'fonts'];
foreach ($assetsFolders as $folder) {
if (!in_array($folder, $allowedInAssets, true)) {
$result->addWarning("Carpeta en ui/assets/: '{$folder}' (esperadas: " . implode(', ', $allowedInAssets) . ")");
}
}
}
}
private function validateAPIStructure(string $apiPath, ValidationResult $result): void
{
if (!is_dir($apiPath)) {
return;
}
$items = scandir($apiPath);
$folders = array_filter($items, function($item) use ($apiPath) {
return $item !== '.' && $item !== '..' && is_dir($apiPath . '/' . $item);
});
$allowedInAPI = ['wordpress'];
foreach ($folders as $folder) {
if (!in_array($folder, $allowedInAPI, true)) {
$result->addWarning("Carpeta en api/: '{$folder}' (esperada: wordpress)");
}
}
}
private function validateDepth(string $modulePath, ValidationResult $result): void
{
// Máximo 5 niveles desde módulo: module/layer/category/subcategory/file.php
$maxDepth = 5;
$deepPaths = [];
$this->scanDirectoryDepth($modulePath, $modulePath, 0, $maxDepth, $deepPaths);
if (!empty($deepPaths)) {
foreach ($deepPaths as $path => $depth) {
$result->addWarning("Ruta muy profunda ({$depth} niveles): {$path}");
}
}
}
private function scanDirectoryDepth(string $basePath, string $currentPath, int $currentDepth, int $maxDepth, array &$deepPaths): void
{
if ($currentDepth > $maxDepth) {
$relativePath = str_replace($basePath . '/', '', $currentPath);
$deepPaths[$relativePath] = $currentDepth;
return;
}
if (!is_dir($currentPath)) {
return;
}
$items = scandir($currentPath);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$itemPath = $currentPath . '/' . $item;
if (is_dir($itemPath)) {
$this->scanDirectoryDepth($basePath, $itemPath, $currentDepth + 1, $maxDepth, $deepPaths);
}
}
}
private function findForbiddenFolders(string $modulePath, ValidationResult $result): void
{
$foundForbidden = [];
$this->scanForForbiddenNames($modulePath, $modulePath, $foundForbidden);
if (!empty($foundForbidden)) {
foreach ($foundForbidden as $path) {
$relativePath = str_replace($modulePath . '/', '', $path);
$folderName = basename($path);
$result->addError("❌ Carpeta PROHIBIDA encontrada: {$relativePath}/ ('{$folderName}' NO es arquitectónicamente válido)");
}
} else {
$result->addInfo("✓ Sin carpetas prohibidas (helpers, utils, etc.)");
}
}
private function scanForForbiddenNames(string $basePath, string $currentPath, array &$foundForbidden): void
{
if (!is_dir($currentPath)) {
return;
}
$items = scandir($currentPath);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$itemPath = $currentPath . '/' . $item;
if (is_dir($itemPath)) {
// Verificar si el nombre de la carpeta está prohibido
if (in_array(strtolower($item), self::FORBIDDEN_NAMES, true)) {
$foundForbidden[] = $itemPath;
}
// Recursivo
$this->scanForForbiddenNames($basePath, $itemPath, $foundForbidden);
}
}
}
public function getPhaseNumber(): int|string
{
return 'structure';
}
public function getPhaseDescription(): string
{
return 'Folder Structure (Clean Architecture)';
}
}

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Fase 01: Schema JSON
*
* Valida que el archivo JSON del componente cumple con:
* - Estructura correcta
* - Campos obligatorios presentes
* - Tipos de datos válidos
* - Grupos y campos requeridos
*/
final class Phase01Validator implements PhaseValidatorInterface
{
private const ALLOWED_TYPES = ['boolean', 'text', 'textarea', 'url', 'select', 'color'];
private const ALLOWED_PRIORITIES = [10, 20, 30, 40, 50, 60, 70, 80, 90];
private const STANDARD_GROUPS = [
'visibility', 'content', 'typography', 'colors', 'spacing',
'visual_effects', 'behavior', 'layout', 'links', 'icons', 'media', 'forms'
];
/**
* Componentes especiales que NO requieren grupo visibility
*
* Estos son componentes de inyeccion (no visuales) que:
* - NO renderizan HTML visual
* - Inyectan codigo en hooks (wp_head, wp_footer)
* - Siempre estan activos (controlados por campos vacios/llenos)
*/
private const INJECTION_COMPONENTS = ['theme-settings'];
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
// Construir ruta al schema
$schemaPath = $themePath . '/Schemas/' . $componentName . '.json';
$result->addInfo("Validando Schema JSON: schemas/{$componentName}.json");
// 1. Verificar que el archivo existe
if (!file_exists($schemaPath)) {
$result->addError("Schema JSON no encontrado: {$schemaPath}");
return $result;
}
// 2. Leer y parsear JSON
$jsonContent = file_get_contents($schemaPath);
$schema = json_decode($jsonContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$result->addError("JSON inválido: " . json_last_error_msg());
return $result;
}
// 3. Validar estructura top-level
$this->validateTopLevelStructure($schema, $componentName, $result);
// 4. Validar grupos
if (isset($schema['groups'])) {
$this->validateGroups($schema['groups'], $result);
}
// 5. Validar campos obligatorios de visibilidad (excepto componentes de inyeccion)
$this->validateVisibilityFields($schema, $componentName, $result);
// Estadísticas
$totalFields = $this->countTotalFields($schema);
$totalGroups = isset($schema['groups']) ? count($schema['groups']) : 0;
$result->setStat('Archivo', "schemas/{$componentName}.json");
$result->setStat('Grupos totales', $totalGroups);
$result->setStat('Campos totales', $totalFields);
$result->setStat('Tamaño JSON', strlen($jsonContent) . ' bytes');
return $result;
}
private function validateTopLevelStructure(array $schema, string $componentName, ValidationResult $result): void
{
// Campos obligatorios
$requiredFields = ['component_name', 'version', 'description', 'groups'];
foreach ($requiredFields as $field) {
if (!isset($schema[$field])) {
$result->addError("Campo obligatorio faltante: '{$field}'");
}
}
// Validar component_name coincide con archivo
if (isset($schema['component_name']) && $schema['component_name'] !== $componentName) {
$result->addError(
"component_name '{$schema['component_name']}' no coincide con nombre de archivo '{$componentName}'"
);
}
// Validar versión semver
if (isset($schema['version'])) {
if (!preg_match('/^\d+\.\d+\.\d+$/', $schema['version'])) {
$result->addError("Versión '{$schema['version']}' no es semver válido (debe ser X.Y.Z)");
}
}
}
private function validateGroups(array $groups, ValidationResult $result): void
{
if (empty($groups)) {
$result->addError("Schema debe tener al menos un grupo");
return;
}
foreach ($groups as $groupName => $group) {
// Validar nombre de grupo es snake_case
if (!preg_match('/^[a-z_]+$/', $groupName)) {
$result->addError("Nombre de grupo '{$groupName}' debe estar en snake_case (solo minúsculas y _)");
}
// Advertencia si grupo no es estándar
if (!in_array($groupName, self::STANDARD_GROUPS, true)) {
$result->addWarning("Grupo '{$groupName}' no es estándar (considerar usar: " . implode(', ', self::STANDARD_GROUPS) . ")");
}
// Validar estructura del grupo
if (!isset($group['label'])) {
$result->addError("Grupo '{$groupName}' no tiene 'label'");
}
if (!isset($group['priority'])) {
$result->addError("Grupo '{$groupName}' no tiene 'priority'");
} elseif (!in_array($group['priority'], self::ALLOWED_PRIORITIES, true)) {
$result->addError(
"Grupo '{$groupName}' tiene priority inválido ({$group['priority']}). " .
"Debe ser uno de: " . implode(', ', self::ALLOWED_PRIORITIES)
);
}
if (!isset($group['fields'])) {
$result->addError("Grupo '{$groupName}' no tiene 'fields'");
} elseif (!is_array($group['fields']) || empty($group['fields'])) {
$result->addError("Grupo '{$groupName}' debe tener al menos un campo");
} else {
$this->validateFields($groupName, $group['fields'], $result);
}
}
}
private function validateFields(string $groupName, array $fields, ValidationResult $result): void
{
foreach ($fields as $fieldName => $field) {
$fullFieldName = "{$groupName}.{$fieldName}";
// Validar nombre de campo es snake_case
if (!preg_match('/^[a-z_]+$/', $fieldName)) {
$result->addError("Campo '{$fullFieldName}' debe estar en snake_case (solo minúsculas y _)");
}
// Campos obligatorios
if (!isset($field['type'])) {
$result->addError("Campo '{$fullFieldName}' no tiene 'type'");
} elseif (!in_array($field['type'], self::ALLOWED_TYPES, true)) {
$result->addError(
"Campo '{$fullFieldName}' tiene type inválido '{$field['type']}'. " .
"Debe ser uno de: " . implode(', ', self::ALLOWED_TYPES)
);
}
if (!isset($field['label'])) {
$result->addError("Campo '{$fullFieldName}' no tiene 'label'");
}
if (!array_key_exists('default', $field)) {
$result->addError("Campo '{$fullFieldName}' no tiene 'default'");
}
if (!isset($field['editable'])) {
$result->addError("Campo '{$fullFieldName}' no tiene 'editable'");
} elseif (!is_bool($field['editable'])) {
$result->addError("Campo '{$fullFieldName}' tiene 'editable' que no es boolean");
}
// Si type es select, debe tener options
if (isset($field['type']) && $field['type'] === 'select') {
if (!isset($field['options']) || !is_array($field['options']) || empty($field['options'])) {
$result->addError("Campo '{$fullFieldName}' es type 'select' pero no tiene array 'options' válido");
}
}
// Si tiene required, debe ser boolean
if (isset($field['required']) && !is_bool($field['required'])) {
$result->addError("Campo '{$fullFieldName}' tiene 'required' que no es boolean");
}
}
}
private function validateVisibilityFields(array $schema, string $componentName, ValidationResult $result): void
{
// Componentes de inyeccion no requieren grupo visibility
if (in_array($componentName, self::INJECTION_COMPONENTS, true)) {
$result->addInfo("✓ Componente de inyección '{$componentName}' - grupo visibility no requerido");
return;
}
if (!isset($schema['groups']['visibility'])) {
$result->addError("Grupo 'visibility' es obligatorio y no está presente");
return;
}
$visibilityFields = $schema['groups']['visibility']['fields'] ?? [];
// Campos obligatorios de visibilidad
$requiredVisibilityFields = [
'is_enabled' => 'boolean',
'show_on_desktop' => 'boolean',
'show_on_mobile' => 'boolean',
];
foreach ($requiredVisibilityFields as $fieldName => $expectedType) {
if (!isset($visibilityFields[$fieldName])) {
$result->addError("Campo obligatorio de visibilidad faltante: 'visibility.{$fieldName}'");
} elseif (isset($visibilityFields[$fieldName]['type']) && $visibilityFields[$fieldName]['type'] !== $expectedType) {
$result->addError(
"Campo 'visibility.{$fieldName}' debe ser type '{$expectedType}' " .
"(encontrado: '{$visibilityFields[$fieldName]['type']}')"
);
}
}
}
private function countTotalFields(array $schema): int
{
$count = 0;
if (isset($schema['groups'])) {
foreach ($schema['groups'] as $group) {
if (isset($group['fields'])) {
$count += count($group['fields']);
}
}
}
return $count;
}
public function getPhaseNumber(): int|string
{
return 1;
}
public function getPhaseDescription(): string
{
return 'Schema JSON';
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Fase 02: Sincronización JSON→BD
*
* Valida que:
* - Schema JSON existe y es válido
* - Tabla de BD existe
* - Todos los campos del JSON están sincronizados en BD
* - No hay campos huérfanos en BD
* - is_editable coincide entre JSON y BD
* - No hay duplicados
*/
final class Phase02Validator implements PhaseValidatorInterface
{
private const TABLE_NAME = 'wp_roi_theme_component_settings';
public function validate(string $componentName, string $themePath): ValidationResult
{
global $wpdb;
$result = new ValidationResult();
$result->addInfo("Validando sincronización JSON→BD para: {$componentName}");
// 1. Verificar que schema JSON existe
$schemaPath = $themePath . '/Schemas/' . $componentName . '.json';
if (!file_exists($schemaPath)) {
$result->addError("Schema JSON no encontrado: {$schemaPath}");
$result->addInfo("Ejecutar primero: wp roi-theme sync-component {$componentName}");
return $result;
}
// 2. Parsear JSON
$jsonContent = file_get_contents($schemaPath);
$schema = json_decode($jsonContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$result->addError("JSON inválido: " . json_last_error_msg());
return $result;
}
// 3. Verificar que tabla existe
$tableName = $wpdb->prefix . 'roi_theme_component_settings';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$tableExists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s",
$tableName
));
if ($tableExists == 0) {
$result->addError("Tabla '{$tableName}' no existe en la base de datos");
$result->addInfo("La tabla debería crearse automáticamente en functions.php");
return $result;
}
// 4. Obtener todos los campos del JSON
$jsonFields = $this->extractFieldsFromSchema($schema);
$totalJsonFields = count($jsonFields);
// 5. Obtener todos los registros de BD para este componente
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$dbRecords = $wpdb->get_results($wpdb->prepare(
"SELECT component_name, group_name, attribute_name, is_editable FROM {$tableName} WHERE component_name = %s",
$componentName
), ARRAY_A);
$totalDbRecords = count($dbRecords);
// 6. Validar sincronización
$this->validateSync($componentName, $jsonFields, $dbRecords, $result);
// 7. Validar no hay duplicados
$this->validateNoDuplicates($componentName, $tableName, $wpdb, $result);
// Estadísticas
$result->setStat('Schema JSON', "schemas/{$componentName}.json");
$result->setStat('Campos en JSON', $totalJsonFields);
$result->setStat('Registros en BD', $totalDbRecords);
$result->setStat('Tabla BD', $tableName);
return $result;
}
/**
* Extrae todos los campos del schema JSON
*
* @param array $schema
* @return array Array de arrays con ['group' => '', 'attribute' => '', 'editable' => bool]
*/
private function extractFieldsFromSchema(array $schema): array
{
$fields = [];
if (!isset($schema['groups'])) {
return $fields;
}
foreach ($schema['groups'] as $groupName => $group) {
if (!isset($group['fields'])) {
continue;
}
foreach ($group['fields'] as $attributeName => $field) {
$fields[] = [
'group' => $groupName,
'attribute' => $attributeName,
'editable' => $field['editable'] ?? false,
];
}
}
return $fields;
}
private function validateSync(string $componentName, array $jsonFields, array $dbRecords, ValidationResult $result): void
{
// Crear índice de registros de BD para búsqueda rápida
$dbIndex = [];
foreach ($dbRecords as $record) {
$key = $record['group_name'] . '.' . $record['attribute_name'];
$dbIndex[$key] = $record;
}
// Validar que cada campo del JSON está en BD
$missingInDb = [];
$editableMismatch = [];
foreach ($jsonFields as $field) {
$key = $field['group'] . '.' . $field['attribute'];
if (!isset($dbIndex[$key])) {
$missingInDb[] = $key;
} else {
// Validar is_editable coincide
$dbEditable = (bool) $dbIndex[$key]['is_editable'];
$jsonEditable = $field['editable'];
if ($dbEditable !== $jsonEditable) {
$editableMismatch[] = "{$key} (JSON: " . ($jsonEditable ? 'true' : 'false') .
", BD: " . ($dbEditable ? 'true' : 'false') . ")";
}
// Remover de índice para detectar huérfanos
unset($dbIndex[$key]);
}
}
// Campos faltantes en BD
if (!empty($missingInDb)) {
foreach ($missingInDb as $field) {
$result->addError("Campo '{$field}' existe en JSON pero NO en BD");
}
$result->addInfo("Ejecutar: wp roi-theme sync-component {$componentName}");
}
// Campos huérfanos en BD (no están en JSON)
if (!empty($dbIndex)) {
foreach ($dbIndex as $key => $record) {
$result->addWarning("Campo '{$key}' existe en BD pero NO en JSON (campo huérfano)");
}
}
// is_editable no coincide
if (!empty($editableMismatch)) {
foreach ($editableMismatch as $mismatch) {
$result->addError("Campo {$mismatch} tiene is_editable diferente entre JSON y BD");
}
$result->addInfo("Ejecutar: wp roi-theme sync-component {$componentName}");
}
// Si todo está sincronizado
if (empty($missingInDb) && empty($editableMismatch) && empty($dbIndex)) {
$result->addInfo("✓ Todos los campos están sincronizados correctamente");
}
}
private function validateNoDuplicates(string $componentName, string $tableName, $wpdb, ValidationResult $result): void
{
// Buscar duplicados
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$duplicates = $wpdb->get_results($wpdb->prepare(
"SELECT component_name, group_name, attribute_name, COUNT(*) as count
FROM {$tableName}
WHERE component_name = %s
GROUP BY component_name, group_name, attribute_name
HAVING count > 1",
$componentName
), ARRAY_A);
if (!empty($duplicates)) {
foreach ($duplicates as $dup) {
$result->addError(
"Duplicado en BD: {$dup['group_name']}.{$dup['attribute_name']} " .
"({$dup['count']} registros)"
);
}
$result->addInfo("El constraint UNIQUE debería prevenir duplicados. Revisar integridad de BD.");
}
}
public function getPhaseNumber(): int|string
{
return 2;
}
public function getPhaseDescription(): string
{
return 'JSON→DB Sync';
}
}

View File

@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Fase 03: Renderers (DB→HTML/CSS)
*
* Valida que:
* - Renderer existe en ubicación correcta
* - Namespace y clase correctos
* - Inyecta CSSGeneratorInterface
* - CERO CSS hardcodeado (CRÍTICO)
* - Tiene métodos obligatorios (render, getVisibilityClasses)
* - Usa escaping correcto
* - NO usa WordPress de BD
*/
final class Phase03Validator implements PhaseValidatorInterface
{
/**
* Componentes especiales que NO requieren CSSGeneratorInterface ni getVisibilityClasses
*
* Estos son componentes de inyeccion (no visuales) que:
* - NO renderizan HTML visual
* - NO generan CSS dinamico (inyectan CSS del usuario tal cual)
* - NO necesitan clases de visibilidad responsive
* - Inyectan codigo en hooks (wp_head, wp_footer)
*/
private const INJECTION_COMPONENTS = ['theme-settings'];
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validando Renderer para: {$componentName}");
// Determinar contexto (admin o public) - intentar ambos
$rendererPath = $this->findRendererPath($componentName, $themePath);
if ($rendererPath === null) {
$result->addError("Renderer no encontrado en Public/ ni Admin/");
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
$result->addInfo("Ubicación esperada: Public/{$pascalCaseName}/Infrastructure/Ui/*Renderer.php");
return $result;
}
$result->addInfo("Archivo encontrado: {$rendererPath}");
// Leer contenido del archivo
$content = file_get_contents($rendererPath);
// Validaciones
$this->validateNamespaceAndClass($content, $componentName, $result);
// Validaciones especiales para componentes de inyeccion
$isInjectionComponent = in_array($componentName, self::INJECTION_COMPONENTS, true);
if ($isInjectionComponent) {
$result->addInfo("✓ Componente de inyección - validaciones CSS/visibility omitidas");
} else {
$this->validateCSSGeneratorInjection($content, $result);
$this->validateNoCSSHardcoded($content, $result); // CRÍTICO
$this->validateGetVisibilityClassesMethod($content, $result);
}
$this->validateRenderMethod($content, $componentName, $result);
$this->validateEscaping($content, $result);
$this->validateNoDirectDatabaseAccess($content, $result);
// Estadísticas
$fileSize = filesize($rendererPath);
$lineCount = substr_count($content, "\n") + 1;
$result->setStat('Archivo', basename($rendererPath));
$result->setStat('Líneas', $lineCount);
$result->setStat('Tamaño', $fileSize . ' bytes');
if ($lineCount > 500) {
$result->addWarning("Archivo excede 500 líneas ({$lineCount}) - considerar refactorizar.");
} elseif ($lineCount > 300) {
$result->addInfo(" Archivo tiene {$lineCount} líneas (recomendado: <300, aceptable: <500)");
}
return $result;
}
private function findRendererPath(string $componentName, string $themePath): ?string
{
// Convertir kebab-case a PascalCase (para carpetas y archivos)
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
// Intentar en Public/ con PascalCase (PSR-4 standard)
$publicPath = $themePath . '/Public/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'Renderer.php';
if (file_exists($publicPath)) {
return $publicPath;
}
// Intentar en Admin/ con PascalCase (PSR-4 standard)
$adminPath = $themePath . '/Admin/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'Renderer.php';
if (file_exists($adminPath)) {
return $adminPath;
}
return null;
}
private function validateNamespaceAndClass(string $content, string $componentName, ValidationResult $result): void
{
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
// Validar namespace (Ui en PascalCase, primera letra mayúscula)
if (!preg_match('/namespace\s+ROITheme\\\\(Public|Admin)\\\\' . preg_quote($pascalCaseName, '/') . '\\\\Infrastructure\\\\Ui;/', $content)) {
$result->addError("Namespace incorrecto. Debe ser: ROITheme\\Public\\{$pascalCaseName}\\Infrastructure\\Ui");
}
// Validar clase final
if (!preg_match('/final\s+class\s+' . preg_quote($pascalCaseName, '/') . 'Renderer/', $content)) {
$result->addError("Clase debe ser: final class {$pascalCaseName}Renderer");
}
}
private function validateCSSGeneratorInjection(string $content, ValidationResult $result): void
{
// Verificar que constructor recibe CSSGeneratorInterface
if (!preg_match('/public\s+function\s+__construct\([^)]*CSSGeneratorInterface\s+\$cssGenerator/s', $content)) {
$result->addError("Constructor NO inyecta CSSGeneratorInterface (debe recibir interfaz, no clase concreta)");
}
// Verificar propiedad privada
if (!preg_match('/private\s+CSSGeneratorInterface\s+\$cssGenerator/', $content)) {
$result->addError("Falta propiedad: private CSSGeneratorInterface \$cssGenerator");
}
}
private function validateRenderMethod(string $content, string $componentName, ValidationResult $result): void
{
// Verificar método render existe (aceptar Component object o array - Component es preferido)
$hasComponentSignature = preg_match('/public\s+function\s+render\s*\(\s*Component\s+\$component\s*\)\s*:\s*string/', $content);
$hasArraySignature = preg_match('/public\s+function\s+render\s*\(\s*array\s+\$data\s*\)\s*:\s*string/', $content);
if (!$hasComponentSignature && !$hasArraySignature) {
$result->addError("Falta método: public function render(Component \$component): string o render(array \$data): string");
} elseif ($hasArraySignature) {
$result->addWarning("Método usa render(array \$data) - Considerar migrar a render(Component \$component) para type safety");
}
// Componentes de inyeccion no requieren validacion de visibility
$isInjectionComponent = in_array($componentName, self::INJECTION_COMPONENTS, true);
if (!$isInjectionComponent) {
// Verificar que valida is_enabled
$hasArrayValidation = preg_match('/\$data\s*\[\s*[\'"]visibility[\'"]\s*\]\s*\[\s*[\'"]is_enabled[\'"]\s*\]/', $content);
$hasComponentValidation = preg_match('/\$component->getVisibility\(\)->isEnabled\(\)/', $content);
if (!$hasArrayValidation && !$hasComponentValidation) {
$result->addWarning("Método render() debería validar visibilidad (is_enabled)");
}
}
}
private function validateNoCSSHardcoded(string $content, ValidationResult $result): void
{
$violations = [];
// Detectar style="..." (pero permitir algunos casos específicos del design system)
if (preg_match_all('/style\s*=\s*["\']/', $content, $matches, PREG_OFFSET_CAPTURE)) {
// Contar ocurrencias
$count = count($matches[0]);
// Si hay más de 2-3 (casos permitidos del design system), es violación
if ($count > 3) {
$violations[] = "Encontrado style=\"...\" inline ({$count} ocurrencias) - PROHIBIDO";
} elseif ($count > 0) {
$result->addWarning("Encontrado {$count} uso(s) de style=\"...\" - Verificar que sea del design system aprobado");
}
}
// Detectar heredoc <<<STYLE o <<<CSS
if (preg_match('/<<<(STYLE|CSS)/', $content)) {
$violations[] = "Encontrado heredoc <<<STYLE o <<<CSS - PROHIBIDO";
}
// Detectar eventos inline (onclick, onmouseover, etc.)
$inlineEvents = ['onclick', 'onmouseover', 'onmouseout', 'onload', 'onchange', 'onsubmit'];
foreach ($inlineEvents as $event) {
if (preg_match('/' . $event . '\s*=/', $content)) {
$violations[] = "Encontrado evento inline '{$event}=\"...\"' - PROHIBIDO";
}
}
// Verificar que usa $this->cssGenerator->generate()
if (!preg_match('/\$this->cssGenerator->generate\s*\(/', $content)) {
$violations[] = "NO usa \$this->cssGenerator->generate() - CSS debe generarse vía servicio";
}
if (!empty($violations)) {
foreach ($violations as $violation) {
$result->addError("❌ CRÍTICO - CSS HARDCODEADO: {$violation}");
}
} else {
$result->addInfo("✓ CERO CSS hardcodeado detectado");
}
}
private function validateGetVisibilityClassesMethod(string $content, ValidationResult $result): void
{
// Verificar firma del método
if (!preg_match('/private\s+function\s+getVisibilityClasses\s*\(\s*bool\s+\$desktop\s*,\s*bool\s+\$mobile\s*\)\s*:\s*\?string/', $content)) {
$result->addError("Falta método: private function getVisibilityClasses(bool \$desktop, bool \$mobile): ?string");
return;
}
// Verificar implementación de tabla Bootstrap
$requiredPatterns = [
'/d-none d-lg-block/' => "Patrón 'd-none d-lg-block' (desktop only)",
'/d-lg-none/' => "Patrón 'd-lg-none' (mobile only)",
];
foreach ($requiredPatterns as $pattern => $description) {
if (!preg_match($pattern, $content)) {
$result->addWarning("getVisibilityClasses() puede no implementar correctamente: {$description}");
}
}
}
private function validateEscaping(string $content, ValidationResult $result): void
{
$escapingUsed = false;
if (preg_match('/esc_html\s*\(/', $content)) {
$escapingUsed = true;
}
if (preg_match('/esc_attr\s*\(/', $content)) {
$escapingUsed = true;
}
if (preg_match('/esc_url\s*\(/', $content)) {
$escapingUsed = true;
}
if (!$escapingUsed) {
$result->addWarning("No se detectó uso de esc_html(), esc_attr() o esc_url() - Verificar escaping");
}
}
private function validateNoDirectDatabaseAccess(string $content, ValidationResult $result): void
{
// Verificar que NO usa global $wpdb
if (preg_match('/global\s+\$wpdb/', $content)) {
$result->addError("Renderer usa 'global \$wpdb' - Acceso a BD debe estar en Repository, NO en Renderer");
}
// Verificar que NO usa funciones directas de BD
$dbFunctions = ['get_option', 'update_option', 'get_post_meta', 'update_post_meta'];
foreach ($dbFunctions as $func) {
if (preg_match('/\b' . $func . '\s*\(/', $content)) {
$result->addWarning("Renderer usa '{$func}()' - Considerar mover a Repository");
}
}
}
public function getPhaseNumber(): int|string
{
return 3;
}
public function getPhaseDescription(): string
{
return 'Renderers (DB→HTML/CSS)';
}
}

View File

@@ -0,0 +1,365 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Fase 04: FormBuilders (UI Admin)
*
* Valida que:
* - FormBuilder existe en ubicación correcta (admin/)
* - Namespace y clase correctos
* - Inyecta AdminDashboardRenderer
* - Tiene método buildForm()
* - Es modular (métodos privados build*)
* - Usa AdminDashboardRenderer correctamente
* - Usa Bootstrap 5
* - Escaping correcto
* - NO accede directamente a BD
*/
final class Phase04Validator implements PhaseValidatorInterface
{
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validando FormBuilder para: {$componentName}");
// Buscar FormBuilder en Admin/
$formBuilderPath = $this->findFormBuilderPath($componentName, $themePath);
if ($formBuilderPath === null) {
$result->addError("FormBuilder no encontrado en Admin/");
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
$result->addInfo("Ubicación esperada: Admin/{$pascalCaseName}/Infrastructure/Ui/*FormBuilder.php");
return $result;
}
$result->addInfo("Archivo encontrado: {$formBuilderPath}");
// Leer contenido
$content = file_get_contents($formBuilderPath);
// Validaciones del FormBuilder
$this->validateNamespaceAndClass($content, $componentName, $result);
$this->validateRendererInjection($content, $result);
$this->validateBuildFormMethod($content, $result);
$this->validateModularity($content, $result);
$this->validateRendererUsage($content, $result);
$this->validateBootstrapCompliance($content, $result);
$this->validateEscaping($content, $result);
$this->validateNoDirectDatabaseAccess($content, $result);
// Validaciones de integración con Admin Panel (CRÍTICAS)
$this->validateComponentRegistration($componentName, $themePath, $result);
$this->validateAjaxFieldMapping($componentName, $themePath, $content, $result);
$this->validateResetButton($componentName, $content, $result);
// Estadísticas
$fileSize = filesize($formBuilderPath);
$lineCount = substr_count($content, "\n") + 1;
$buildMethodsCount = $this->countBuildMethods($content);
$result->setStat('Archivo', basename($formBuilderPath));
$result->setStat('Líneas', $lineCount);
$result->setStat('Métodos build*', $buildMethodsCount);
$result->setStat('Tamaño', $fileSize . ' bytes');
if ($lineCount > 500) {
$result->addWarning("Archivo excede 500 líneas ({$lineCount}) - considerar dividir en múltiples FormBuilders.");
} elseif ($lineCount > 300) {
$result->addInfo(" Archivo tiene {$lineCount} líneas (recomendado: <300, aceptable: <500)");
}
if ($buildMethodsCount < 2) {
$result->addWarning("Solo {$buildMethodsCount} método(s) build*. FormBuilders modulares deben tener al menos 2-3 métodos privados.");
}
return $result;
}
private function findFormBuilderPath(string $componentName, string $themePath): ?string
{
// Convertir kebab-case a PascalCase (para carpetas y archivos)
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
// Buscar en Admin/ con PascalCase (PSR-4 standard)
$adminPath = $themePath . '/Admin/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'FormBuilder.php';
if (file_exists($adminPath)) {
return $adminPath;
}
return null;
}
private function validateNamespaceAndClass(string $content, string $componentName, ValidationResult $result): void
{
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
// Validar namespace (Ui en PascalCase, primera letra mayúscula)
if (!preg_match('/namespace\s+ROITheme\\\\Admin\\\\' . preg_quote($pascalCaseName, '/') . '\\\\Infrastructure\\\\Ui;/', $content)) {
$result->addError("Namespace incorrecto. Debe ser: ROITheme\\Admin\\{$pascalCaseName}\\Infrastructure\\Ui");
}
// Validar clase final
if (!preg_match('/final\s+class\s+' . preg_quote($pascalCaseName, '/') . 'FormBuilder/', $content)) {
$result->addError("Clase debe ser: final class {$pascalCaseName}FormBuilder");
}
}
private function validateRendererInjection(string $content, ValidationResult $result): void
{
// Verificar que constructor recibe AdminDashboardRenderer
if (!preg_match('/public\s+function\s+__construct\([^)]*AdminDashboardRenderer\s+\$renderer/s', $content)) {
$result->addError("Constructor NO inyecta AdminDashboardRenderer");
}
// Verificar propiedad privada
if (!preg_match('/private\s+AdminDashboardRenderer\s+\$renderer/', $content)) {
$result->addError("Falta propiedad: private AdminDashboardRenderer \$renderer");
}
}
private function validateBuildFormMethod(string $content, ValidationResult $result): void
{
// Verificar método buildForm existe
if (!preg_match('/public\s+function\s+buildForm\s*\(\s*string\s+\$componentId\s*\)\s*:\s*string/', $content)) {
$result->addError("Falta método: public function buildForm(string \$componentId): string");
}
}
private function validateModularity(string $content, ValidationResult $result): void
{
// Contar métodos privados build*
$buildMethodsCount = $this->countBuildMethods($content);
if ($buildMethodsCount === 0) {
$result->addError("FormBuilder NO tiene métodos privados build* - Debe ser modular");
} elseif ($buildMethodsCount < 2) {
$result->addWarning("Solo {$buildMethodsCount} método build* - Considerar más modularidad");
} else {
$result->addInfo("✓ Modularidad: {$buildMethodsCount} métodos build* detectados");
}
}
private function countBuildMethods(string $content): int
{
preg_match_all('/private\s+function\s+build[A-Z][a-zA-Z]*\s*\(/', $content, $matches);
return count($matches[0]);
}
private function validateRendererUsage(string $content, ValidationResult $result): void
{
// Verificar que usa $this->renderer->getFieldValue()
if (!preg_match('/\$this->renderer->getFieldValue\s*\(/', $content)) {
$result->addWarning("No se detectó uso de \$this->renderer->getFieldValue() - Verificar que obtiene datos del renderer");
}
}
private function validateBootstrapCompliance(string $content, ValidationResult $result): void
{
$bootstrapClasses = [
'form-control' => false,
'form-select' => false,
'form-check' => false,
'card' => false,
'row' => false,
];
foreach ($bootstrapClasses as $class => $found) {
// Buscar clase dentro de atributos class="..." o class='...'
// Acepta: class="row", class="row g-3", class="form-check form-switch", etc.
if (preg_match('/class=["\'][^"\']*\b' . preg_quote($class, '/') . '\b[^"\']*["\']/', $content)) {
$bootstrapClasses[$class] = true;
}
}
$usedClasses = array_keys(array_filter($bootstrapClasses));
if (count($usedClasses) === 0) {
$result->addWarning("No se detectaron clases Bootstrap 5 - Verificar que usa Bootstrap");
} else {
$result->addInfo("✓ Bootstrap 5: Usa " . implode(', ', $usedClasses));
}
}
private function validateEscaping(string $content, ValidationResult $result): void
{
$escapingUsed = false;
if (preg_match('/esc_attr\s*\(/', $content)) {
$escapingUsed = true;
}
if (preg_match('/esc_html\s*\(/', $content)) {
$escapingUsed = true;
}
if (!$escapingUsed) {
$result->addWarning("No se detectó uso de esc_attr() o esc_html() - Verificar escaping de valores");
}
}
private function validateNoDirectDatabaseAccess(string $content, ValidationResult $result): void
{
// Verificar que NO usa global $wpdb
if (preg_match('/global\s+\$wpdb/', $content)) {
$result->addError("FormBuilder usa 'global \$wpdb' - Acceso a BD debe estar en Repository o AdminDashboardRenderer");
}
// Verificar que NO usa funciones directas de BD
$dbFunctions = ['get_option', 'update_option', 'get_post_meta', 'update_post_meta'];
foreach ($dbFunctions as $func) {
if (preg_match('/\b' . $func . '\s*\(/', $content)) {
$result->addError("FormBuilder usa '{$func}()' - Debe usar AdminDashboardRenderer, NO acceder BD directamente");
}
}
}
/**
* Valida que el componente está registrado en AdminDashboardRenderer::getComponents()
*/
private function validateComponentRegistration(string $componentName, string $themePath, ValidationResult $result): void
{
$dashboardRendererPath = $themePath . '/Admin/Infrastructure/Ui/AdminDashboardRenderer.php';
if (!file_exists($dashboardRendererPath)) {
$result->addWarning("No se encontró AdminDashboardRenderer.php - No se puede validar registro del componente");
return;
}
$dashboardContent = file_get_contents($dashboardRendererPath);
// Buscar si el componente está registrado en getComponents()
// Busca patrones como: 'cta-lets-talk' => [ o "cta-lets-talk" => [
$pattern = '/[\'"]' . preg_quote($componentName, '/') . '[\'"]\s*=>\s*\[/';
if (!preg_match($pattern, $dashboardContent)) {
$result->addError("Componente NO registrado en AdminDashboardRenderer::getComponents()");
$result->addInfo("Agregar en Admin/Infrastructure/Ui/AdminDashboardRenderer.php método getComponents():");
$result->addInfo("'{$componentName}' => ['id' => '{$componentName}', 'label' => '...', 'icon' => 'bi-...'],");
} else {
$result->addInfo("✓ Componente registrado en AdminDashboardRenderer::getComponents()");
}
}
/**
* Valida que existe mapeo AJAX para los campos del FormBuilder
*/
private function validateAjaxFieldMapping(string $componentName, string $themePath, string $formBuilderContent, ValidationResult $result): void
{
$ajaxHandlerPath = $themePath . '/Admin/Infrastructure/Api/Wordpress/AdminAjaxHandler.php';
if (!file_exists($ajaxHandlerPath)) {
$result->addWarning("No se encontró AdminAjaxHandler.php - No se puede validar mapeo AJAX");
return;
}
$ajaxContent = file_get_contents($ajaxHandlerPath);
// Extraer todos los IDs de campos del FormBuilder (id="...")
preg_match_all('/id=["\']([a-zA-Z0-9_]+)["\']/', $formBuilderContent, $fieldMatches);
$formFieldIds = array_unique($fieldMatches[1]);
// Filtrar solo los IDs que corresponden a inputs (no contenedores)
// Los IDs de inputs generalmente tienen un patrón como componentNameFieldName
$fieldPrefix = $this->getFieldPrefix($componentName);
$inputFieldIds = array_filter($formFieldIds, function($id) use ($fieldPrefix) {
// Solo IDs que empiezan con el prefijo del componente
return stripos($id, $fieldPrefix) === 0;
});
if (empty($inputFieldIds)) {
$result->addWarning("No se detectaron campos con prefijo '{$fieldPrefix}' en FormBuilder");
return;
}
// Verificar que cada campo tiene mapeo en getFieldMapping()
$unmappedFields = [];
foreach ($inputFieldIds as $fieldId) {
// Buscar el ID en el contenido del AjaxHandler
if (!preg_match('/[\'"]' . preg_quote($fieldId, '/') . '[\'"]\s*=>/', $ajaxContent)) {
$unmappedFields[] = $fieldId;
}
}
$totalFields = count($inputFieldIds);
$mappedFields = $totalFields - count($unmappedFields);
if (count($unmappedFields) > 0) {
$result->addError("Mapeo AJAX incompleto: {$mappedFields}/{$totalFields} campos mapeados");
$result->addInfo("Campos sin mapeo en AdminAjaxHandler::getFieldMapping():");
foreach (array_slice($unmappedFields, 0, 5) as $field) {
$result->addInfo(" - {$field}");
}
if (count($unmappedFields) > 5) {
$result->addInfo(" ... y " . (count($unmappedFields) - 5) . " más");
}
} else {
$result->addInfo("✓ Mapeo AJAX completo: {$totalFields}/{$totalFields} campos mapeados");
}
}
/**
* Valida que el botón "Restaurar valores por defecto" sigue el patrón correcto
*
* El patrón correcto es:
* - class="btn-reset-defaults"
* - data-component="nombre-componente"
*
* NO usar id="reset*Defaults" (patrón antiguo hardcodeado)
*/
private function validateResetButton(string $componentName, string $content, ValidationResult $result): void
{
// Verificar patrón correcto: class="btn-reset-defaults" data-component="..."
$correctPattern = '/class=["\'][^"\']*btn-reset-defaults[^"\']*["\'][^>]*data-component=["\']' . preg_quote($componentName, '/') . '["\']/';
$correctPatternAlt = '/data-component=["\']' . preg_quote($componentName, '/') . '["\'][^>]*class=["\'][^"\']*btn-reset-defaults[^"\']*["\']/';
$hasCorrectPattern = preg_match($correctPattern, $content) || preg_match($correctPatternAlt, $content);
// Verificar patrón incorrecto: id="reset*Defaults" (hardcodeado)
$incorrectPattern = '/id=["\']reset[A-Za-z]+Defaults["\']/';
$hasIncorrectPattern = preg_match($incorrectPattern, $content);
if ($hasIncorrectPattern && !$hasCorrectPattern) {
$result->addError("Botón reset usa patrón hardcodeado (id=\"reset*Defaults\")");
$result->addInfo("Cambiar a: class=\"btn-reset-defaults\" data-component=\"{$componentName}\"");
} elseif (!$hasCorrectPattern) {
$result->addWarning("No se detectó botón 'Restaurar valores por defecto' con patrón dinámico");
$result->addInfo("Agregar: <button class=\"btn-reset-defaults\" data-component=\"{$componentName}\">...</button>");
} else {
$result->addInfo("✓ Botón reset con patrón dinámico correcto (data-component=\"{$componentName}\")");
}
}
/**
* Obtiene el prefijo de campos para un componente
* Permite prefijos personalizados más cortos para componentes con nombres largos
*/
private function getFieldPrefix(string $componentName): string
{
// Mapeo de prefijos personalizados (más cortos/legibles)
$customPrefixes = [
'top-notification-bar' => 'topBar',
];
if (isset($customPrefixes[$componentName])) {
return $customPrefixes[$componentName];
}
// Por defecto: convertir kebab-case a camelCase
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
return lcfirst($pascalCaseName);
}
public function getPhaseNumber(): int|string
{
return 4;
}
public function getPhaseDescription(): string
{
return 'FormBuilders (UI Admin)';
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Fase 05: Validación General SOLID
*
* Escanea TODOS los archivos .php del componente y valida:
* - Domain Purity (sin WordPress, sin echo, sin HTML)
* - Application Purity (sin WordPress)
* - CERO CSS hardcodeado
* - Dependency Injection
* - SRP (Single Responsibility - tamaño de archivos)
* - ISP (Interface Segregation - tamaño de interfaces)
* - Encapsulación (propiedades private/protected)
* - NO instanciación directa en Domain/Application
*/
final class Phase05Validator implements PhaseValidatorInterface
{
private const WORDPRESS_FUNCTIONS = [
'global \$wpdb',
'add_action',
'add_filter',
'get_option',
'update_option',
'wp_enqueue_',
'register_post_type',
'add_shortcode',
'\$_POST',
'\$_GET',
'\$_SESSION',
'\$_COOKIE',
];
private const CSS_PATTERNS = [
'style\s*=\s*["\']', // style="..."
'<<<STYLE', // HEREDOC con STYLE
'<<<CSS', // HEREDOC con CSS
'onmouseover\s*=', // inline JS events
'onmouseout\s*=',
'onclick\s*=',
'onload\s*=',
'onchange\s*=',
];
private int $filesScanned = 0;
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validación SOLID general para: {$componentName}");
// Buscar directorio del componente
$componentPath = $this->findComponentPath($componentName, $themePath);
if ($componentPath === null) {
$result->addError("Componente no encontrado en Admin/ ni Public/");
return $result;
}
$result->addInfo("Escaneando: {$componentPath}");
// Escanear todos los archivos .php
$this->filesScanned = 0;
$this->scanDirectory($componentPath, $result);
// Estadísticas
$result->setStat('Archivos escaneados', $this->filesScanned);
return $result;
}
private function findComponentPath(string $componentName, string $themePath): ?string
{
// Convertir kebab-case a PascalCase (PSR-4 standard)
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
// Intentar en Public/ con PascalCase
$publicPath = $themePath . '/Public/' . $pascalCaseName;
if (is_dir($publicPath)) {
return $publicPath;
}
// Intentar en Admin/ con PascalCase
$adminPath = $themePath . '/Admin/' . $pascalCaseName;
if (is_dir($adminPath)) {
return $adminPath;
}
return null;
}
private function scanDirectory(string $dir, ValidationResult $result): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$this->validateFile($file->getPathname(), $result);
}
}
}
private function validateFile(string $filePath, ValidationResult $result): void
{
$this->filesScanned++;
$content = file_get_contents($filePath);
$fileName = basename($filePath);
// Detectar capa (Domain, Application, Infrastructure)
$layer = $this->detectLayer($filePath);
// REGLA 1: Domain NO puede tener WordPress
if ($layer === 'domain') {
$this->checkDomainPurity($content, $fileName, $result);
}
// REGLA 2: Application NO puede tener WordPress
if ($layer === 'application') {
$this->checkApplicationPurity($content, $fileName, $result);
}
// REGLA 3: CERO CSS hardcodeado en PHP (general)
$this->checkNoHardcodedCSS($content, $fileName, $result);
// REGLA 4: DIP - Constructores deben recibir interfaces
if ($layer === 'infrastructure') {
$this->checkDependencyInjection($content, $fileName, $result);
}
// REGLA 5: SRP - Archivos no deben exceder 300 líneas
$this->checkFileLength($content, $fileName, $result);
// REGLA 6: ISP - Interfaces no deben ser gordas
if (strpos($filePath, 'Interface.php') !== false) {
$this->checkInterfaceSize($content, $fileName, $result);
}
// REGLA 7: Propiedades deben ser private/protected
$this->checkEncapsulation($content, $fileName, $result);
// REGLA 8: NO debe haber new ConcreteClass() en Domain/Application
if ($layer === 'domain' || $layer === 'application') {
$this->checkNoDirectInstantiation($content, $fileName, $result);
}
}
private function detectLayer(string $filePath): ?string
{
if (stripos($filePath, DIRECTORY_SEPARATOR . 'domain' . DIRECTORY_SEPARATOR) !== false) {
return 'domain';
}
if (stripos($filePath, DIRECTORY_SEPARATOR . 'application' . DIRECTORY_SEPARATOR) !== false) {
return 'application';
}
if (stripos($filePath, DIRECTORY_SEPARATOR . 'infrastructure' . DIRECTORY_SEPARATOR) !== false) {
return 'infrastructure';
}
return null;
}
private function checkDomainPurity(string $content, string $file, ValidationResult $result): void
{
foreach (self::WORDPRESS_FUNCTIONS as $wpFunction) {
if (preg_match('/' . $wpFunction . '/i', $content)) {
$result->addError("Domain tiene código WordPress '{$wpFunction}': {$file}");
}
}
// Domain NO debe tener echo/print
if (preg_match('/\b(echo|print|print_r|var_dump)\s+/', $content)) {
$result->addError("Domain tiene output directo (echo/print/var_dump): {$file}");
}
// Domain NO debe tener HTML
if (preg_match('/<(div|span|p|a|ul|li|table|form|input|button|h[1-6])\s/i', $content)) {
$result->addError("Domain tiene HTML hardcodeado: {$file}");
}
}
private function checkApplicationPurity(string $content, string $file, ValidationResult $result): void
{
foreach (self::WORDPRESS_FUNCTIONS as $wpFunction) {
if (preg_match('/' . $wpFunction . '/i', $content)) {
$result->addError("Application tiene código WordPress '{$wpFunction}': {$file}");
}
}
}
private function checkNoHardcodedCSS(string $content, string $file, ValidationResult $result): void
{
// Skip si es un archivo de assets o FormBuilder (ya validado en Phase04)
if (strpos($file, 'assets') !== false || strpos($file, 'FormBuilder') !== false) {
return;
}
$violations = 0;
foreach (self::CSS_PATTERNS as $pattern) {
if (preg_match('/' . $pattern . '/i', $content)) {
$violations++;
}
}
if ($violations > 0) {
$result->addError("CSS hardcodeado encontrado ({$violations} patrones): {$file}");
}
}
private function checkDependencyInjection(string $content, string $file, ValidationResult $result): void
{
// Buscar constructores
if (preg_match('/__construct\s*\([^)]*\)/', $content, $matches)) {
$constructor = $matches[0];
// Verificar si recibe parámetros
if (strpos($constructor, '$') !== false) {
// Buscar parámetros que son clases concretas (Service, Repository, Manager)
if (preg_match('/private\s+([A-Z][a-zA-Z]+)(Service|Repository|Manager)\s+\$/', $constructor)) {
// Verificar si NO termina en Interface
if (!preg_match('/interface\s+\$/', $constructor)) {
$result->addWarning("Constructor recibe clase concreta en lugar de interface: {$file}");
}
}
}
}
// Verificar si tiene new dentro del código (excepto DTOs, VOs, Entities)
if (preg_match('/new\s+([A-Z][a-zA-Z]+)(Service|Repository|Manager|Controller|Builder)\s*\(/i', $content)) {
$result->addError("Instanciación directa de clase de infraestructura (debe usar DI): {$file}");
}
}
private function checkFileLength(string $content, string $file, ValidationResult $result): void
{
$lines = substr_count($content, "\n") + 1;
if ($lines > 500) {
$result->addWarning("Archivo excede 500 líneas ({$lines}) - considerar refactorizar: {$file}");
} elseif ($lines > 300) {
$result->addInfo(" Archivo tiene {$lines} líneas (recomendado: <300, aceptable: <500): {$file}");
}
}
private function checkInterfaceSize(string $content, string $file, ValidationResult $result): void
{
// Contar métodos públicos en la interface
$methodCount = preg_match_all('/public\s+function\s+\w+\s*\(/i', $content);
if ($methodCount > 10) {
$result->addError("Interface tiene {$methodCount} métodos (máx 10) - viola ISP: {$file}");
} elseif ($methodCount > 5) {
$result->addWarning("Interface tiene {$methodCount} métodos (recomendado: 3-5) - considerar dividir: {$file}");
}
}
private function checkEncapsulation(string $content, string $file, ValidationResult $result): void
{
// Buscar propiedades public (excluir const y function)
if (preg_match('/\bpublic\s+(?!const|function|static\s+function)\$\w+/i', $content)) {
$result->addWarning("Propiedades públicas encontradas - usar private/protected: {$file}");
}
}
private function checkNoDirectInstantiation(string $content, string $file, ValidationResult $result): void
{
// Permitir new de: DTOs, Value Objects, Entities, Exceptions
$allowedPatterns = [
'DTO', 'Request', 'Response', 'Exception',
'ComponentName', 'ComponentConfiguration', 'ComponentVisibility',
'Color', 'Url', 'Email', 'ComponentId', 'MenuItem',
'self', 'static', 'parent', 'stdClass', 'DateTime'
];
if (preg_match_all('/new\s+([A-Z][a-zA-Z]+)\s*\(/i', $content, $matches)) {
foreach ($matches[1] as $className) {
$isAllowed = false;
foreach ($allowedPatterns as $pattern) {
if (strpos($className, $pattern) !== false || $className === $pattern) {
$isAllowed = true;
break;
}
}
if (!$isAllowed) {
$result->addWarning("Instanciación directa de '{$className}' en Domain/Application - considerar inyección: {$file}");
}
}
}
}
public function getPhaseNumber(): int|string
{
return 5;
}
public function getPhaseDescription(): string
{
return 'General SOLID (todos los archivos)';
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Contrato para validadores de fases
*
* Cada fase del flujo de trabajo tiene un validador específico
* que verifica que la implementación cumple con los estándares
*/
interface PhaseValidatorInterface
{
/**
* Valida un componente para la fase específica
*
* @param string $componentName Nombre del componente en kebab-case (ej: 'top-notification-bar')
* @param string $themePath Ruta absoluta al tema de WordPress
* @return ValidationResult Resultado de la validación con errores, warnings e info
*/
public function validate(string $componentName, string $themePath): ValidationResult;
/**
* Retorna el número de fase que valida
*
* @return int|string 1, 2, 3, 4, 5 o 'structure'
*/
public function getPhaseNumber(): int|string;
/**
* Retorna descripción corta de qué valida esta fase
*
* @return string Descripción breve (ej: 'Schema JSON', 'JSON→DB Sync')
*/
public function getPhaseDescription(): string;
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de llamadas a roi_render_component() en templates
*
* Verifica que:
* - Los nombres de componentes usen kebab-case (guiones, no underscores)
* - Los componentes llamados estén registrados en functions-addon.php
* - No haya llamadas con nombres inválidos
*/
final class TemplateCallsValidator implements PhaseValidatorInterface
{
public function getPhaseNumber(): int|string
{
return 'templates';
}
public function getPhaseDescription(): string
{
return 'Template Calls (roi_render_component)';
}
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validando llamadas a roi_render_component() para: {$componentName}");
// 1. Obtener componentes registrados en functions-addon.php
$registeredComponents = $this->getRegisteredComponents($themePath);
$result->addInfo("Componentes registrados: " . count($registeredComponents));
// 2. Buscar todas las llamadas en templates
$templateFiles = $this->getTemplateFiles($themePath);
$allCalls = [];
$invalidCalls = [];
$unregisteredCalls = [];
foreach ($templateFiles as $file) {
$calls = $this->findRenderCalls($file);
foreach ($calls as $call) {
$allCalls[] = [
'file' => basename($file),
'line' => $call['line'],
'component' => $call['component']
];
// Verificar si usa underscore en lugar de guión
if (strpos($call['component'], '_') !== false) {
$invalidCalls[] = $call + ['file' => basename($file)];
}
// Verificar si está registrado
if (!in_array($call['component'], $registeredComponents)) {
// Solo marcar como no registrado si no tiene underscore
// (los de underscore ya se marcan como inválidos)
if (strpos($call['component'], '_') === false) {
$unregisteredCalls[] = $call + ['file' => basename($file)];
}
}
}
}
// 3. Verificar específicamente el componente que se está validando
$componentFound = false;
$componentCallsCorrect = true;
foreach ($allCalls as $call) {
// Buscar variantes del componente (con guión o underscore)
$kebabName = $componentName;
$snakeName = str_replace('-', '_', $componentName);
if ($call['component'] === $kebabName) {
$componentFound = true;
$result->addInfo("✓ Componente '{$componentName}' llamado correctamente en {$call['file']}:{$call['line']}");
} elseif ($call['component'] === $snakeName) {
$componentFound = true;
$componentCallsCorrect = false;
$result->addError(
"Llamada incorrecta en {$call['file']}:{$call['line']}: " .
"usa '{$snakeName}' (underscore) en lugar de '{$kebabName}' (kebab-case)"
);
}
}
// 4. Reportar errores generales de formato
foreach ($invalidCalls as $call) {
// Solo reportar si no es el componente actual (ya se reportó arriba)
$snakeName = str_replace('-', '_', $componentName);
if ($call['component'] !== $snakeName) {
$suggestedName = str_replace('_', '-', $call['component']);
$result->addWarning(
"Llamada con underscore en {$call['file']}:{$call['line']}: " .
"'{$call['component']}' debería ser '{$suggestedName}'"
);
}
}
// 5. Reportar componentes no registrados
foreach ($unregisteredCalls as $call) {
$result->addWarning(
"Componente no registrado en {$call['file']}:{$call['line']}: '{$call['component']}'"
);
}
// 6. Advertir si el componente no se llama en ningún template
if (!$componentFound) {
$result->addWarning(
"El componente '{$componentName}' no se llama en ningún template. " .
"Verifica que esté incluido en single.php, header.php, footer.php u otro template."
);
}
// Estadísticas
$result->setStat('Templates escaneados', count($templateFiles));
$result->setStat('Llamadas totales encontradas', count($allCalls));
$result->setStat('Componentes registrados', count($registeredComponents));
if (!empty($invalidCalls)) {
$result->setStat('Llamadas con underscore (error)', count($invalidCalls));
}
return $result;
}
/**
* Obtiene la lista de componentes registrados en functions-addon.php
*/
private function getRegisteredComponents(string $themePath): array
{
$functionsFile = $themePath . '/functions-addon.php';
$components = [];
if (!file_exists($functionsFile)) {
return $components;
}
$content = file_get_contents($functionsFile);
// Buscar patrones: case 'nombre-componente':
if (preg_match_all("/case\s+'([a-z0-9-]+)':/", $content, $matches)) {
$components = $matches[1];
}
return array_unique($components);
}
/**
* Obtiene los archivos de template PHP del tema
*/
private function getTemplateFiles(string $themePath): array
{
$templates = [];
// Archivos principales de template
$mainTemplates = [
'header.php',
'footer.php',
'single.php',
'page.php',
'index.php',
'archive.php',
'search.php',
'sidebar.php',
'404.php',
'front-page.php',
'home.php',
];
foreach ($mainTemplates as $template) {
$path = $themePath . '/' . $template;
if (file_exists($path)) {
$templates[] = $path;
}
}
// Buscar en template-parts/
$templatePartsDir = $themePath . '/template-parts';
if (is_dir($templatePartsDir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($templatePartsDir)
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$templates[] = $file->getPathname();
}
}
}
// Buscar templates personalizados (page-*.php, single-*.php)
$customTemplates = glob($themePath . '/{page-*.php,single-*.php}', GLOB_BRACE);
if ($customTemplates) {
$templates = array_merge($templates, $customTemplates);
}
return $templates;
}
/**
* Busca llamadas a roi_render_component() en un archivo
*/
private function findRenderCalls(string $filePath): array
{
$calls = [];
$content = file_get_contents($filePath);
$lines = explode("\n", $content);
foreach ($lines as $lineNum => $line) {
// Buscar: roi_render_component('nombre') o roi_render_component("nombre")
if (preg_match_all("/roi_render_component\s*\(\s*['\"]([^'\"]+)['\"]\s*\)/", $line, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$calls[] = [
'line' => $lineNum + 1,
'component' => $match[1]
];
}
}
}
return $calls;
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Resultado de una validación
*
* Almacena errores, advertencias, información y estadísticas
*/
final class ValidationResult
{
/**
* @param bool $success Estado inicial de éxito
* @param array<string> $errors Lista de errores críticos
* @param array<string> $warnings Lista de advertencias
* @param array<string> $info Lista de mensajes informativos
* @param array<string, mixed> $stats Estadísticas de la validación
*/
public function __construct(
private bool $success = true,
private array $errors = [],
private array $warnings = [],
private array $info = [],
private array $stats = []
) {}
/**
* Verifica si la validación fue exitosa
*
* @return bool True si no hay errores, false si hay al menos un error
*/
public function isSuccess(): bool
{
return $this->success && empty($this->errors);
}
/**
* Obtiene todos los errores críticos
*
* @return array<string>
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Obtiene todas las advertencias
*
* @return array<string>
*/
public function getWarnings(): array
{
return $this->warnings;
}
/**
* Obtiene todos los mensajes informativos
*
* @return array<string>
*/
public function getInfo(): array
{
return $this->info;
}
/**
* Obtiene todas las estadísticas
*
* @return array<string, mixed>
*/
public function getStats(): array
{
return $this->stats;
}
/**
* Agrega un error crítico
*
* Al agregar un error, la validación se marca como fallida
*
* @param string $message Mensaje del error
* @return void
*/
public function addError(string $message): void
{
$this->errors[] = $message;
$this->success = false;
}
/**
* Agrega una advertencia
*
* Las advertencias NO marcan la validación como fallida
*
* @param string $message Mensaje de advertencia
* @return void
*/
public function addWarning(string $message): void
{
$this->warnings[] = $message;
}
/**
* Agrega un mensaje informativo
*
* @param string $message Mensaje informativo
* @return void
*/
public function addInfo(string $message): void
{
$this->info[] = $message;
}
/**
* Establece una estadística
*
* @param string $key Clave de la estadística
* @param mixed $value Valor de la estadística
* @return void
*/
public function setStat(string $key, mixed $value): void
{
$this->stats[$key] = $value;
}
/**
* Cuenta total de errores
*
* @return int
*/
public function getErrorCount(): int
{
return count($this->errors);
}
/**
* Cuenta total de advertencias
*
* @return int
*/
public function getWarningCount(): int
{
return count($this->warnings);
}
/**
* Marca la validación como fallida manualmente
*
* @return void
*/
public function markAsFailed(): void
{
$this->success = false;
}
}