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:
320
Shared/Infrastructure/Adapters/LegacyDBManagerAdapter.php
Normal file
320
Shared/Infrastructure/Adapters/LegacyDBManagerAdapter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
0
Shared/Infrastructure/Api/WordPress/.gitkeep
Normal file
0
Shared/Infrastructure/Api/WordPress/.gitkeep
Normal file
215
Shared/Infrastructure/Api/WordPress/AjaxController.php
Normal file
215
Shared/Infrastructure/Api/WordPress/AjaxController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
305
Shared/Infrastructure/Api/WordPress/MigrationCommand.php
Normal file
305
Shared/Infrastructure/Api/WordPress/MigrationCommand.php
Normal 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);
|
||||
}
|
||||
236
Shared/Infrastructure/Di/DIContainer.php
Normal file
236
Shared/Infrastructure/Di/DIContainer.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
141
Shared/Infrastructure/Facades/ComponentManager.php
Normal file
141
Shared/Infrastructure/Facades/ComponentManager.php
Normal 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();
|
||||
}
|
||||
}
|
||||
350
Shared/Infrastructure/Logging/DeprecationLogger.php
Normal file
350
Shared/Infrastructure/Logging/DeprecationLogger.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
133
Shared/Infrastructure/README.md
Normal file
133
Shared/Infrastructure/README.md
Normal 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`
|
||||
210
Shared/Infrastructure/Scripts/validate-architecture.php
Normal file
210
Shared/Infrastructure/Scripts/validate-architecture.php
Normal 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";
|
||||
}
|
||||
0
Shared/Infrastructure/Services/.gitkeep
Normal file
0
Shared/Infrastructure/Services/.gitkeep
Normal file
180
Shared/Infrastructure/Services/CSSGeneratorService.php
Normal file
180
Shared/Infrastructure/Services/CSSGeneratorService.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
51
Shared/Infrastructure/Services/CleanupService.php
Normal file
51
Shared/Infrastructure/Services/CleanupService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
164
Shared/Infrastructure/Services/SchemaSyncService.php
Normal file
164
Shared/Infrastructure/Services/SchemaSyncService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
124
Shared/Infrastructure/Services/WordPressCacheService.php
Normal file
124
Shared/Infrastructure/Services/WordPressCacheService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
172
Shared/Infrastructure/Services/WordPressValidationService.php
Normal file
172
Shared/Infrastructure/Services/WordPressValidationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
0
Shared/Infrastructure/Traits/.gitkeep
Normal file
0
Shared/Infrastructure/Traits/.gitkeep
Normal file
697
Shared/Infrastructure/Ui/TopNotificationBarFormBuilder.php
Normal file
697
Shared/Infrastructure/Ui/TopNotificationBarFormBuilder.php
Normal 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';
|
||||
}
|
||||
}
|
||||
299
Shared/Infrastructure/Validators/CSSConflictValidator.php
Normal file
299
Shared/Infrastructure/Validators/CSSConflictValidator.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
333
Shared/Infrastructure/Validators/FolderStructureValidator.php
Normal file
333
Shared/Infrastructure/Validators/FolderStructureValidator.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
255
Shared/Infrastructure/Validators/Phase01Validator.php
Normal file
255
Shared/Infrastructure/Validators/Phase01Validator.php
Normal 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';
|
||||
}
|
||||
}
|
||||
215
Shared/Infrastructure/Validators/Phase02Validator.php
Normal file
215
Shared/Infrastructure/Validators/Phase02Validator.php
Normal 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';
|
||||
}
|
||||
}
|
||||
272
Shared/Infrastructure/Validators/Phase03Validator.php
Normal file
272
Shared/Infrastructure/Validators/Phase03Validator.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
365
Shared/Infrastructure/Validators/Phase04Validator.php
Normal file
365
Shared/Infrastructure/Validators/Phase04Validator.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
305
Shared/Infrastructure/Validators/Phase05Validator.php
Normal file
305
Shared/Infrastructure/Validators/Phase05Validator.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
36
Shared/Infrastructure/Validators/PhaseValidatorInterface.php
Normal file
36
Shared/Infrastructure/Validators/PhaseValidatorInterface.php
Normal 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;
|
||||
}
|
||||
225
Shared/Infrastructure/Validators/TemplateCallsValidator.php
Normal file
225
Shared/Infrastructure/Validators/TemplateCallsValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
157
Shared/Infrastructure/Validators/ValidationResult.php
Normal file
157
Shared/Infrastructure/Validators/ValidationResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user