diff --git a/Admin/CustomCSSManager/Application/DTOs/SaveSnippetRequest.php b/Admin/CustomCSSManager/Application/DTOs/SaveSnippetRequest.php
new file mode 100644
index 00000000..83343eaa
--- /dev/null
+++ b/Admin/CustomCSSManager/Application/DTOs/SaveSnippetRequest.php
@@ -0,0 +1,73 @@
+ $pages Páginas donde aplicar: ['all'], ['home', 'posts'], etc.
+ * @param bool $enabled Si el snippet está activo
+ * @param int $order Orden de carga (menor = primero)
+ */
+ public function __construct(
+ public readonly string $id,
+ public readonly string $name,
+ public readonly string $description,
+ public readonly string $css,
+ public readonly string $type,
+ public readonly array $pages,
+ public readonly bool $enabled,
+ public readonly int $order
+ ) {}
+
+ /**
+ * Factory desde array (formulario o API)
+ *
+ * @param array $data Datos del formulario
+ * @return self
+ */
+ public static function fromArray(array $data): self
+ {
+ return new self(
+ id: $data['id'] ?? '',
+ name: $data['name'] ?? '',
+ description: $data['description'] ?? '',
+ css: $data['css'] ?? '',
+ type: $data['type'] ?? 'deferred',
+ pages: $data['pages'] ?? ['all'],
+ enabled: (bool)($data['enabled'] ?? true),
+ order: (int)($data['order'] ?? 100)
+ );
+ }
+
+ /**
+ * Convierte a array para persistencia
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'css' => $this->css,
+ 'type' => $this->type,
+ 'pages' => $this->pages,
+ 'enabled' => $this->enabled,
+ 'order' => $this->order,
+ ];
+ }
+}
diff --git a/Admin/CustomCSSManager/Application/UseCases/DeleteSnippetUseCase.php b/Admin/CustomCSSManager/Application/UseCases/DeleteSnippetUseCase.php
new file mode 100644
index 00000000..b8f2f143
--- /dev/null
+++ b/Admin/CustomCSSManager/Application/UseCases/DeleteSnippetUseCase.php
@@ -0,0 +1,29 @@
+repository->delete($snippetId);
+ }
+}
diff --git a/Admin/CustomCSSManager/Application/UseCases/GetAllSnippetsUseCase.php b/Admin/CustomCSSManager/Application/UseCases/GetAllSnippetsUseCase.php
new file mode 100644
index 00000000..b7768c3d
--- /dev/null
+++ b/Admin/CustomCSSManager/Application/UseCases/GetAllSnippetsUseCase.php
@@ -0,0 +1,28 @@
+ Lista de snippets ordenados por 'order'
+ */
+ public function execute(): array
+ {
+ return $this->repository->getAll();
+ }
+}
diff --git a/Admin/CustomCSSManager/Application/UseCases/SaveSnippetUseCase.php b/Admin/CustomCSSManager/Application/UseCases/SaveSnippetUseCase.php
new file mode 100644
index 00000000..63b58e6d
--- /dev/null
+++ b/Admin/CustomCSSManager/Application/UseCases/SaveSnippetUseCase.php
@@ -0,0 +1,35 @@
+toArray());
+
+ // 2. Validar en dominio
+ $snippet->validate();
+
+ // 3. Validar tamaño según tipo
+ $snippet->css()->validateForLoadType($snippet->loadType());
+
+ // 4. Persistir
+ $this->repository->save($snippet->toArray());
+ }
+}
diff --git a/Admin/CustomCSSManager/Domain/Entities/CSSSnippet.php b/Admin/CustomCSSManager/Domain/Entities/CSSSnippet.php
new file mode 100644
index 00000000..7fe0643d
--- /dev/null
+++ b/Admin/CustomCSSManager/Domain/Entities/CSSSnippet.php
@@ -0,0 +1,115 @@
+name)) {
+ throw new ValidationException('El nombre del snippet es requerido');
+ }
+
+ if (strlen($this->name) > 100) {
+ throw new ValidationException('El nombre no puede exceder 100 caracteres');
+ }
+
+ // CSS ya validado en Value Object CSSCode
+ }
+
+ /**
+ * Convierte a array para persistencia
+ */
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id->value(),
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'css' => $this->css->value(),
+ 'type' => $this->loadType->value(),
+ 'pages' => $this->pages,
+ 'enabled' => $this->enabled,
+ 'order' => $this->order,
+ ];
+ }
+
+ // Getters
+ public function id(): SnippetId
+ {
+ return $this->id;
+ }
+
+ public function name(): string
+ {
+ return $this->name;
+ }
+
+ public function css(): CSSCode
+ {
+ return $this->css;
+ }
+
+ public function loadType(): LoadType
+ {
+ return $this->loadType;
+ }
+
+ public function pages(): array
+ {
+ return $this->pages;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function order(): int
+ {
+ return $this->order;
+ }
+}
diff --git a/Admin/CustomCSSManager/Domain/ValueObjects/CSSCode.php b/Admin/CustomCSSManager/Domain/ValueObjects/CSSCode.php
new file mode 100644
index 00000000..b95375d9
--- /dev/null
+++ b/Admin/CustomCSSManager/Domain/ValueObjects/CSSCode.php
@@ -0,0 +1,74 @@
+
+ $css = preg_replace('/<\/?style[^>]*>/i', '', $css);
+
+ // Eliminar comentarios HTML
+ $css = preg_replace('//s', '', $css);
+
+ return trim($css);
+ }
+
+ private static function validate(string $css): void
+ {
+ // Detectar código potencialmente peligroso
+ $dangerous = ['javascript:', 'expression(', '@import', 'behavior:'];
+ foreach ($dangerous as $pattern) {
+ if (stripos($css, $pattern) !== false) {
+ throw new ValidationException("CSS contiene patrón no permitido: {$pattern}");
+ }
+ }
+ }
+
+ public function validateForLoadType(LoadType $loadType): void
+ {
+ $maxSize = $loadType->isCritical()
+ ? self::MAX_SIZE_CRITICAL
+ : self::MAX_SIZE_DEFERRED;
+
+ if (strlen($this->value) > $maxSize) {
+ throw new ValidationException(
+ sprintf('CSS excede el tamaño máximo de %d bytes para tipo %s',
+ $maxSize,
+ $loadType->value()
+ )
+ );
+ }
+ }
+
+ public function value(): string
+ {
+ return $this->value;
+ }
+
+ public function isEmpty(): bool
+ {
+ return empty($this->value);
+ }
+}
diff --git a/Admin/CustomCSSManager/Domain/ValueObjects/LoadType.php b/Admin/CustomCSSManager/Domain/ValueObjects/LoadType.php
new file mode 100644
index 00000000..a5e444de
--- /dev/null
+++ b/Admin/CustomCSSManager/Domain/ValueObjects/LoadType.php
@@ -0,0 +1,56 @@
+value === 'critical';
+ }
+
+ public function isDeferred(): bool
+ {
+ return $this->value === 'deferred';
+ }
+
+ public function value(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/Admin/CustomCSSManager/Domain/ValueObjects/SnippetId.php b/Admin/CustomCSSManager/Domain/ValueObjects/SnippetId.php
new file mode 100644
index 00000000..7f0f91a1
--- /dev/null
+++ b/Admin/CustomCSSManager/Domain/ValueObjects/SnippetId.php
@@ -0,0 +1,121 @@
+ 50) {
+ throw new ValidationException('El ID del snippet no puede exceder 50 caracteres');
+ }
+
+ // Validar formato generado (css_*)
+ if (str_starts_with($id, self::PREFIX)) {
+ if (!preg_match(self::PATTERN_GENERATED, $id)) {
+ throw new ValidationException(
+ sprintf('Formato de ID generado inválido: %s. Esperado: css_[timestamp]_[random]', $id)
+ );
+ }
+ return new self($id);
+ }
+
+ // Validar formato legacy (kebab-case)
+ if (!preg_match(self::PATTERN_LEGACY, $id)) {
+ throw new ValidationException(
+ sprintf('Formato de ID inválido: %s. Use kebab-case (ej: cls-tables-apu)', $id)
+ );
+ }
+
+ return new self($id);
+ }
+
+ /**
+ * Genera un nuevo SnippetId único
+ *
+ * @return self
+ */
+ public static function generate(): self
+ {
+ $timestamp = time();
+ $random = bin2hex(random_bytes(3));
+
+ return new self(self::PREFIX . $timestamp . '_' . $random);
+ }
+
+ /**
+ * Verifica si es un ID generado (vs legacy)
+ *
+ * @return bool
+ */
+ public function isGenerated(): bool
+ {
+ return str_starts_with($this->value, self::PREFIX);
+ }
+
+ /**
+ * Obtiene el valor del ID
+ *
+ * @return string
+ */
+ public function value(): string
+ {
+ return $this->value;
+ }
+
+ /**
+ * Compara igualdad con otro SnippetId
+ *
+ * @param SnippetId $other
+ * @return bool
+ */
+ public function equals(SnippetId $other): bool
+ {
+ return $this->value === $other->value;
+ }
+
+ /**
+ * Representación string
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/Admin/CustomCSSManager/Infrastructure/Persistence/WordPressSnippetRepository.php b/Admin/CustomCSSManager/Infrastructure/Persistence/WordPressSnippetRepository.php
new file mode 100644
index 00000000..50a4789d
--- /dev/null
+++ b/Admin/CustomCSSManager/Infrastructure/Persistence/WordPressSnippetRepository.php
@@ -0,0 +1,163 @@
+wpdb->prefix . 'roi_theme_component_settings';
+
+ $sql = $this->wpdb->prepare(
+ "SELECT attribute_value FROM {$tableName}
+ WHERE component_name = %s
+ AND group_name = %s
+ AND attribute_name = %s
+ LIMIT 1",
+ self::COMPONENT_NAME,
+ self::GROUP_NAME,
+ self::ATTRIBUTE_NAME
+ );
+
+ $json = $this->wpdb->get_var($sql);
+
+ if (empty($json)) {
+ return [];
+ }
+
+ $snippets = json_decode($json, true);
+
+ return is_array($snippets) ? $snippets : [];
+ }
+
+ public function getByLoadType(string $loadType): array
+ {
+ $all = $this->getAll();
+
+ $filtered = array_filter($all, function ($snippet) use ($loadType) {
+ return ($snippet['type'] ?? '') === $loadType
+ && ($snippet['enabled'] ?? false) === true;
+ });
+
+ // Reindexar para evitar keys dispersas [0,2,5] → [0,1,2]
+ return array_values($filtered);
+ }
+
+ public function getForPage(string $loadType, string $pageType): array
+ {
+ $snippets = $this->getByLoadType($loadType);
+
+ $filtered = array_filter($snippets, function ($snippet) use ($pageType) {
+ $pages = $snippet['pages'] ?? ['all'];
+ return in_array('all', $pages, true)
+ || in_array($pageType, $pages, true);
+ });
+
+ // Reindexar para evitar keys dispersas
+ return array_values($filtered);
+ }
+
+ public function save(array $snippet): void
+ {
+ $all = $this->getAll();
+
+ // Actualizar o agregar
+ $found = false;
+ foreach ($all as &$existing) {
+ if ($existing['id'] === $snippet['id']) {
+ $existing = $snippet;
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found) {
+ $all[] = $snippet;
+ }
+
+ // Ordenar por 'order'
+ usort($all, fn($a, $b) => ($a['order'] ?? 100) <=> ($b['order'] ?? 100));
+
+ $this->persist($all);
+ }
+
+ public function delete(string $snippetId): void
+ {
+ $all = $this->getAll();
+
+ $filtered = array_filter($all, fn($s) => $s['id'] !== $snippetId);
+
+ $this->persist(array_values($filtered));
+ }
+
+ /**
+ * Persiste la lista de snippets en BD
+ *
+ * Usa update() + insert() para consistencia con patrón existente.
+ * NOTA: NO usa replace() porque:
+ * - Preserva ID autoincremental
+ * - Preserva campos como is_editable, created_at
+ *
+ * @param array $snippets Lista de snippets a persistir
+ */
+ private function persist(array $snippets): void
+ {
+ $tableName = $this->wpdb->prefix . 'roi_theme_component_settings';
+ $json = wp_json_encode($snippets, JSON_UNESCAPED_UNICODE);
+
+ // Verificar si el registro existe
+ $exists = $this->wpdb->get_var($this->wpdb->prepare(
+ "SELECT COUNT(*) FROM {$tableName}
+ WHERE component_name = %s
+ AND group_name = %s
+ AND attribute_name = %s",
+ self::COMPONENT_NAME,
+ self::GROUP_NAME,
+ self::ATTRIBUTE_NAME
+ ));
+
+ if ($exists > 0) {
+ // UPDATE existente (preserva id, created_at, is_editable)
+ $this->wpdb->update(
+ $tableName,
+ ['attribute_value' => $json],
+ [
+ 'component_name' => self::COMPONENT_NAME,
+ 'group_name' => self::GROUP_NAME,
+ 'attribute_name' => self::ATTRIBUTE_NAME,
+ ],
+ ['%s'],
+ ['%s', '%s', '%s']
+ );
+ } else {
+ // INSERT nuevo
+ $this->wpdb->insert(
+ $tableName,
+ [
+ 'component_name' => self::COMPONENT_NAME,
+ 'group_name' => self::GROUP_NAME,
+ 'attribute_name' => self::ATTRIBUTE_NAME,
+ 'attribute_value' => $json,
+ 'is_editable' => 1,
+ ],
+ ['%s', '%s', '%s', '%s', '%d']
+ );
+ }
+ }
+}
diff --git a/Admin/CustomCSSManager/Infrastructure/Ui/CustomCSSManagerFormBuilder.php b/Admin/CustomCSSManager/Infrastructure/Ui/CustomCSSManagerFormBuilder.php
new file mode 100644
index 00000000..93e49e62
--- /dev/null
+++ b/Admin/CustomCSSManager/Infrastructure/Ui/CustomCSSManagerFormBuilder.php
@@ -0,0 +1,462 @@
+repository = new WordPressSnippetRepository($wpdb);
+ $this->getAllUseCase = new GetAllSnippetsUseCase($this->repository);
+ $this->saveUseCase = new SaveSnippetUseCase($this->repository);
+ $this->deleteUseCase = new DeleteSnippetUseCase($this->repository);
+
+ // Registrar handler de formulario POST
+ $this->registerFormHandler();
+ }
+
+ /**
+ * Registra handler para procesar formularios POST
+ */
+ private function registerFormHandler(): void
+ {
+ // Solo registrar una vez
+ static $registered = false;
+ if ($registered) {
+ return;
+ }
+ $registered = true;
+
+ add_action('admin_init', function() {
+ $this->handleFormSubmission();
+ });
+ }
+
+ /**
+ * Procesa envío de formulario
+ */
+ public function handleFormSubmission(): void
+ {
+ if (!isset($_POST['roi_css_action'])) {
+ return;
+ }
+
+ // Verificar nonce
+ if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', self::NONCE_ACTION)) {
+ wp_die('Nonce verification failed');
+ }
+
+ // Verificar permisos
+ if (!current_user_can('manage_options')) {
+ wp_die('Insufficient permissions');
+ }
+
+ $action = sanitize_text_field($_POST['roi_css_action']);
+
+ try {
+ match ($action) {
+ 'save' => $this->processSave($_POST),
+ 'delete' => $this->processDelete($_POST),
+ default => null,
+ };
+
+ // Redirect con mensaje de éxito
+ wp_redirect(add_query_arg('roi_message', 'success', wp_get_referer()));
+ exit;
+
+ } catch (ValidationException $e) {
+ // Redirect con mensaje de error
+ wp_redirect(add_query_arg([
+ 'roi_message' => 'error',
+ 'roi_error' => urlencode($e->getMessage())
+ ], wp_get_referer()));
+ exit;
+ }
+ }
+
+ /**
+ * Procesa guardado de snippet
+ */
+ private function processSave(array $data): void
+ {
+ $id = sanitize_text_field($data['snippet_id'] ?? '');
+
+ // Generar ID si es nuevo
+ if (empty($id)) {
+ $id = SnippetId::generate()->value();
+ }
+
+ $request = SaveSnippetRequest::fromArray([
+ 'id' => $id,
+ 'name' => sanitize_text_field($data['snippet_name'] ?? ''),
+ 'description' => sanitize_textarea_field($data['snippet_description'] ?? ''),
+ 'css' => wp_strip_all_tags($data['snippet_css'] ?? ''),
+ 'type' => sanitize_text_field($data['snippet_type'] ?? 'deferred'),
+ 'pages' => array_map('sanitize_text_field', $data['snippet_pages'] ?? ['all']),
+ 'enabled' => isset($data['snippet_enabled']),
+ 'order' => absint($data['snippet_order'] ?? 100),
+ ]);
+
+ $this->saveUseCase->execute($request);
+ }
+
+ /**
+ * Procesa eliminación de snippet
+ */
+ private function processDelete(array $data): void
+ {
+ $id = sanitize_text_field($data['snippet_id'] ?? '');
+
+ if (empty($id)) {
+ throw new ValidationException('ID de snippet requerido para eliminar');
+ }
+
+ $this->deleteUseCase->execute($id);
+ }
+
+ /**
+ * Construye el formulario del componente
+ *
+ * @param string $componentId ID del componente (custom-css-manager)
+ * @return string HTML del formulario
+ */
+ public function buildForm(string $componentId): string
+ {
+ $snippets = $this->getAllUseCase->execute();
+ $message = $this->getFlashMessage();
+
+ $html = '';
+
+ // Header
+ $html .= $this->buildHeader($componentId, count($snippets));
+
+ // Mensajes flash
+ if ($message) {
+ $html .= sprintf(
+ '
%s
',
+ esc_attr($message['type']),
+ esc_html($message['text'])
+ );
+ }
+
+ // Lista de snippets existentes
+ $html .= $this->buildSnippetsList($snippets);
+
+ // Formulario de creación/edición
+ $html .= $this->buildSnippetForm();
+
+ // JavaScript
+ $html .= $this->buildJavaScript();
+
+ return $html;
+ }
+
+ /**
+ * Construye el header del componente
+ */
+ private function buildHeader(string $componentId, int $snippetCount): string
+ {
+ $html = '';
+ $html .= ' ';
+ $html .= '
';
+ $html .= '
Gestiona snippets de CSS personalizados. Los snippets críticos se cargan en el head, los diferidos en el footer.
';
+ $html .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ /**
+ * Construye la lista de snippets existentes
+ */
+ private function buildSnippetsList(array $snippets): string
+ {
+ $html = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' Snippets Configurados';
+ $html .= '
';
+
+ if (empty($snippets)) {
+ $html .= '
No hay snippets configurados.
';
+ } else {
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' | Nombre | ';
+ $html .= ' Tipo | ';
+ $html .= ' Páginas | ';
+ $html .= ' Estado | ';
+ $html .= ' Acciones | ';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' ';
+ foreach ($snippets as $snippet) {
+ $html .= $this->renderSnippetRow($snippet);
+ }
+ $html .= ' ';
+ $html .= '
';
+ $html .= '
';
+ }
+
+ $html .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ /**
+ * Renderiza una fila de snippet en la tabla
+ */
+ private function renderSnippetRow(array $snippet): string
+ {
+ $id = esc_attr($snippet['id']);
+ $name = esc_html($snippet['name']);
+ $type = $snippet['type'] === 'critical' ? 'Crítico' : 'Diferido';
+ $typeBadge = $snippet['type'] === 'critical' ? 'bg-danger' : 'bg-info';
+ $pages = implode(', ', $snippet['pages'] ?? ['all']);
+ $enabled = ($snippet['enabled'] ?? false) ? 'Activo' : 'Inactivo';
+ $enabledBadge = ($snippet['enabled'] ?? false) ? 'bg-success' : 'bg-secondary';
+
+ // Usar data-attribute para JSON seguro
+ $snippetJson = esc_attr(wp_json_encode($snippet, JSON_HEX_APOS | JSON_HEX_QUOT));
+ $nonce = wp_create_nonce(self::NONCE_ACTION);
+
+ return <<
+ {$name} |
+ {$type} |
+ {$pages} |
+ {$enabled} |
+
+
+
+ |
+
+ HTML;
+ }
+
+ /**
+ * Construye el formulario de creación/edición de snippets
+ */
+ private function buildSnippetForm(): string
+ {
+ $nonce = wp_create_nonce(self::NONCE_ACTION);
+
+ $html = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' Agregar/Editar Snippet';
+ $html .= '
';
+
+ $html .= '
';
+ $html .= '
';
+ $html .= '
';
+
+ return $html;
+ }
+
+ /**
+ * Genera el JavaScript necesario para el formulario
+ */
+ private function buildJavaScript(): string
+ {
+ return <<
+ // Event delegation para botones de edición
+ document.addEventListener('DOMContentLoaded', function() {
+ document.querySelectorAll('.btn-edit-snippet').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ const snippet = JSON.parse(this.dataset.snippet);
+ editCssSnippet(snippet);
+ });
+ });
+ });
+
+ function editCssSnippet(snippet) {
+ document.getElementById('snippet_id').value = snippet.id;
+ document.getElementById('snippet_name').value = snippet.name;
+ document.getElementById('snippet_description').value = snippet.description || '';
+ document.getElementById('snippet_type').value = snippet.type;
+ document.getElementById('snippet_order').value = snippet.order || 100;
+ document.getElementById('snippet_css').value = snippet.css;
+ document.getElementById('snippet_enabled').checked = snippet.enabled;
+
+ // Actualizar checkboxes de páginas
+ document.querySelectorAll('input[name="snippet_pages[]"]').forEach(cb => {
+ cb.checked = (snippet.pages || ['all']).includes(cb.value);
+ });
+
+ document.getElementById('snippet_name').focus();
+
+ // Scroll al formulario
+ document.getElementById('roi-snippet-form').scrollIntoView({ behavior: 'smooth' });
+ }
+
+ function resetCssForm() {
+ document.getElementById('roi-snippet-form').reset();
+ document.getElementById('snippet_id').value = '';
+ }
+
+ JS;
+ }
+
+ /**
+ * Opciones de páginas disponibles
+ */
+ private function getPageOptions(): array
+ {
+ return [
+ 'all' => 'Todas',
+ 'home' => 'Inicio',
+ 'posts' => 'Posts',
+ 'pages' => 'Páginas',
+ 'archives' => 'Archivos',
+ ];
+ }
+
+ /**
+ * Obtiene mensaje flash de la URL
+ */
+ private function getFlashMessage(): ?array
+ {
+ $message = $_GET['roi_message'] ?? null;
+
+ if ($message === 'success') {
+ return ['type' => 'success', 'text' => 'Snippet guardado correctamente'];
+ }
+
+ if ($message === 'error') {
+ $error = urldecode($_GET['roi_error'] ?? 'Error desconocido');
+ return ['type' => 'danger', 'text' => $error];
+ }
+
+ return null;
+ }
+}
diff --git a/Admin/Infrastructure/Ui/AdminDashboardRenderer.php b/Admin/Infrastructure/Ui/AdminDashboardRenderer.php
index 67391190..7517b079 100644
--- a/Admin/Infrastructure/Ui/AdminDashboardRenderer.php
+++ b/Admin/Infrastructure/Ui/AdminDashboardRenderer.php
@@ -118,6 +118,11 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
'label' => 'AdSense',
'icon' => 'bi-megaphone',
],
+ 'custom-css-manager' => [
+ 'id' => 'custom-css-manager',
+ 'label' => 'CSS Personalizado',
+ 'icon' => 'bi-file-earmark-code',
+ ],
];
}
@@ -160,9 +165,18 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
*/
public function getFormBuilderClass(string $componentId): string
{
- // Convertir kebab-case a PascalCase
- // 'top-notification-bar' → 'TopNotificationBar'
- $className = str_replace('-', '', ucwords($componentId, '-'));
+ // Mapeo especial para componentes con acrónimos (CSS, API, etc.)
+ $specialMappings = [
+ 'custom-css-manager' => 'CustomCSSManager',
+ ];
+
+ // Usar mapeo especial si existe, sino convertir kebab-case a PascalCase
+ if (isset($specialMappings[$componentId])) {
+ $className = $specialMappings[$componentId];
+ } else {
+ // 'top-notification-bar' → 'TopNotificationBar'
+ $className = str_replace('-', '', ucwords($componentId, '-'));
+ }
// Construir namespace completo
// ROITheme\Admin\TopNotificationBar\Infrastructure\Ui\TopNotificationBarFormBuilder
diff --git a/Admin/Infrastructure/Ui/ComponentGroupRegistry.php b/Admin/Infrastructure/Ui/ComponentGroupRegistry.php
index a6da61f3..05464063 100644
--- a/Admin/Infrastructure/Ui/ComponentGroupRegistry.php
+++ b/Admin/Infrastructure/Ui/ComponentGroupRegistry.php
@@ -61,7 +61,7 @@ final class ComponentGroupRegistry
'label' => __('Configuración', 'roi-theme'),
'icon' => 'bi-gear',
'description' => __('Ajustes globales del tema y monetización', 'roi-theme'),
- 'components' => ['theme-settings', 'adsense-placement']
+ 'components' => ['theme-settings', 'adsense-placement', 'custom-css-manager']
],
];
diff --git a/Inc/enqueue-scripts.php b/Inc/enqueue-scripts.php
index 25503773..c500efc7 100644
--- a/Inc/enqueue-scripts.php
+++ b/Inc/enqueue-scripts.php
@@ -351,15 +351,20 @@ function roi_enqueue_generic_tables() {
return;
}
- // Generic Tables CSS
- // DIFERIDO: Fase 4.2 PageSpeed - below the fold
- wp_enqueue_style(
- 'roi-generic-tables',
- get_template_directory_uri() . '/Assets/Css/css-global-generic-tables.css',
- array('roi-bootstrap'),
- ROI_VERSION,
- 'print' // Diferido para no bloquear renderizado
- );
+ // ELIMINADO: Generic Tables CSS
+ // Motivo: Migrado a CustomCSSManager (TIPO 3: CSS Crítico Personalizado)
+ // Los estilos ahora se inyectan dinámicamente desde la BD via CustomCSSInjector
+ // Fecha: 2025-12-01
+ // @see Admin/CustomCSSManager/ - Sistema de gestión de CSS personalizado
+ // @see Public/CustomCSSManager/Infrastructure/Services/CustomCSSInjector.php
+
+ // wp_enqueue_style(
+ // 'roi-generic-tables',
+ // get_template_directory_uri() . '/Assets/Css/css-global-generic-tables.css',
+ // array('roi-bootstrap'),
+ // ROI_VERSION,
+ // 'print'
+ // );
}
add_action('wp_enqueue_scripts', 'roi_enqueue_generic_tables', 11);
@@ -570,43 +575,38 @@ add_action('wp_enqueue_scripts', 'roi_enqueue_theme_styles', 13);
* @see Assets/Css/critical-bootstrap.css - CSS crítico inline
* @see Shared/Infrastructure/Services/CriticalBootstrapService.php
*/
-function roi_enqueue_apu_tables_styles() {
- wp_enqueue_style(
- 'roi-tables-apu',
- get_template_directory_uri() . '/Assets/Css/css-tablas-apu.css',
- array('roi-bootstrap'),
- ROI_VERSION,
- 'print' // media="print" para carga async - se cambia a 'all' via JS
- );
-}
+// ELIMINADO: roi_enqueue_apu_tables_styles y roi_async_apu_tables_css_tag
+// Motivo: Migrado a CustomCSSManager (TIPO 3: CSS Crítico Personalizado)
+// Los estilos de tablas APU ahora se inyectan dinámicamente desde la BD
+// via CustomCSSInjector en wp_footer con id="roi-custom-deferred-css"
+// Fecha: 2025-12-01
+// @see Admin/CustomCSSManager/ - Sistema de gestión de CSS personalizado
+// @see Public/CustomCSSManager/Infrastructure/Services/CustomCSSInjector.php
-add_action('wp_enqueue_scripts', 'roi_enqueue_apu_tables_styles', 5);
+// function roi_enqueue_apu_tables_styles() {
+// wp_enqueue_style(
+// 'roi-tables-apu',
+// get_template_directory_uri() . '/Assets/Css/css-tablas-apu.css',
+// array('roi-bootstrap'),
+// ROI_VERSION,
+// 'print'
+// );
+// }
+// add_action('wp_enqueue_scripts', 'roi_enqueue_apu_tables_styles', 5);
-/**
- * Modificar tag de css-tablas-apu.css para carga async
- *
- * Agrega onload="this.media='all'" para aplicar estilos después de cargar
- * sin bloquear el renderizado inicial.
- *
- * @param string $tag Tag HTML del link
- * @param string $handle Nombre del estilo
- * @return string Tag modificado
- */
-function roi_async_apu_tables_css_tag($tag, $handle) {
- if ($handle === 'roi-tables-apu') {
- // Agregar onload para cambiar media a 'all' cuando cargue
- $tag = str_replace(
- "media='print'",
- "media='print' onload=\"this.media='all'\"",
- $tag
- );
- // Agregar noscript fallback
- $noscript_url = get_template_directory_uri() . '/Assets/Css/css-tablas-apu.css?ver=' . ROI_VERSION;
- $tag .= '';
- }
- return $tag;
-}
-add_filter('style_loader_tag', 'roi_async_apu_tables_css_tag', 10, 2);
+// function roi_async_apu_tables_css_tag($tag, $handle) {
+// if ($handle === 'roi-tables-apu') {
+// $tag = str_replace(
+// "media='print'",
+// "media='print' onload=\"this.media='all'\"",
+// $tag
+// );
+// $noscript_url = get_template_directory_uri() . '/Assets/Css/css-tablas-apu.css?ver=' . ROI_VERSION;
+// $tag .= '';
+// }
+// return $tag;
+// }
+// add_filter('style_loader_tag', 'roi_async_apu_tables_css_tag', 10, 2);
/**
* Enqueue APU Tables auto-class JavaScript
diff --git a/Public/CustomCSSManager/Application/UseCases/GetCriticalSnippetsUseCase.php b/Public/CustomCSSManager/Application/UseCases/GetCriticalSnippetsUseCase.php
new file mode 100644
index 00000000..6d0bfb65
--- /dev/null
+++ b/Public/CustomCSSManager/Application/UseCases/GetCriticalSnippetsUseCase.php
@@ -0,0 +1,27 @@
+ Snippets críticos aplicables
+ */
+ public function execute(string $pageType): array
+ {
+ return $this->repository->getForPage('critical', $pageType);
+ }
+}
diff --git a/Public/CustomCSSManager/Application/UseCases/GetDeferredSnippetsUseCase.php b/Public/CustomCSSManager/Application/UseCases/GetDeferredSnippetsUseCase.php
new file mode 100644
index 00000000..7ea91314
--- /dev/null
+++ b/Public/CustomCSSManager/Application/UseCases/GetDeferredSnippetsUseCase.php
@@ -0,0 +1,30 @@
+ Snippets diferidos aplicables a la página
+ */
+ public function execute(string $pageType): array
+ {
+ return $this->repository->getForPage('deferred', $pageType);
+ }
+}
diff --git a/Public/CustomCSSManager/Infrastructure/Services/CustomCSSInjector.php b/Public/CustomCSSManager/Infrastructure/Services/CustomCSSInjector.php
new file mode 100644
index 00000000..288935df
--- /dev/null
+++ b/Public/CustomCSSManager/Infrastructure/Services/CustomCSSInjector.php
@@ -0,0 +1,126 @@
+
+ */
+ public function injectCriticalCSS(): void
+ {
+ $pageType = $this->getCurrentPageType();
+ $snippets = $this->getCriticalUseCase->execute($pageType);
+
+ if (empty($snippets)) {
+ return;
+ }
+
+ $css = $this->combineSnippets($snippets);
+
+ if (!empty($css)) {
+ printf(
+ '' . "\n",
+ $css
+ );
+ }
+ }
+
+ /**
+ * Inyecta CSS diferido en footer
+ */
+ public function injectDeferredCSS(): void
+ {
+ $pageType = $this->getCurrentPageType();
+ $snippets = $this->getDeferredUseCase->execute($pageType);
+
+ if (empty($snippets)) {
+ return;
+ }
+
+ $css = $this->combineSnippets($snippets);
+
+ if (!empty($css)) {
+ printf(
+ '' . "\n",
+ $css
+ );
+ }
+ }
+
+ /**
+ * Combina múltiples snippets en un solo string CSS
+ *
+ * Aplica sanitización para prevenir inyección de HTML malicioso.
+ */
+ private function combineSnippets(array $snippets): string
+ {
+ $parts = [];
+
+ foreach ($snippets as $snippet) {
+ if (!empty($snippet['css'])) {
+ // Sanitizar CSS: eliminar tags HTML/script
+ $cleanCSS = wp_strip_all_tags($snippet['css']);
+
+ // Eliminar caracteres potencialmente peligrosos
+ $cleanCSS = preg_replace('/<[^>]*>/', '', $cleanCSS);
+
+ $cleanName = sanitize_text_field($snippet['name'] ?? $snippet['id']);
+
+ $parts[] = sprintf(
+ "/* %s */\n%s",
+ $cleanName,
+ $cleanCSS
+ );
+ }
+ }
+
+ return implode("\n\n", $parts);
+ }
+
+ /**
+ * Detecta tipo de página actual
+ */
+ private function getCurrentPageType(): string
+ {
+ if (is_front_page() || is_home()) {
+ return 'home';
+ }
+ if (is_single()) {
+ return 'posts';
+ }
+ if (is_page()) {
+ return 'pages';
+ }
+ if (is_archive() || is_category() || is_tag()) {
+ return 'archives';
+ }
+ return 'all';
+ }
+}
diff --git a/Schemas/custom-css-manager.json b/Schemas/custom-css-manager.json
new file mode 100644
index 00000000..02eb79ce
--- /dev/null
+++ b/Schemas/custom-css-manager.json
@@ -0,0 +1,20 @@
+{
+ "component_name": "custom-css-manager",
+ "version": "1.0.0",
+ "description": "Gestor de CSS personalizado configurable desde Admin Panel",
+ "groups": {
+ "css_snippets": {
+ "label": "Snippets de CSS",
+ "priority": 10,
+ "fields": {
+ "snippets_json": {
+ "type": "textarea",
+ "label": "Configuración JSON de Snippets",
+ "default": "[]",
+ "editable": true,
+ "description": "Array JSON con snippets CSS. Gestionado via UI."
+ }
+ }
+ }
+ }
+}
diff --git a/Shared/Domain/Contracts/CSSSnippetRepositoryInterface.php b/Shared/Domain/Contracts/CSSSnippetRepositoryInterface.php
new file mode 100644
index 00000000..59825397
--- /dev/null
+++ b/Shared/Domain/Contracts/CSSSnippetRepositoryInterface.php
@@ -0,0 +1,45 @@
+ Array de snippets deserializados
+ */
+ public function getAll(): array;
+
+ /**
+ * Guarda un snippet (crear o actualizar)
+ * @param array $snippet Datos del snippet
+ */
+ public function save(array $snippet): void;
+
+ /**
+ * Elimina un snippet por ID
+ * @param string $snippetId ID del snippet
+ */
+ public function delete(string $snippetId): void;
+
+ /**
+ * Obtiene snippets por tipo de carga
+ * @param string $loadType 'critical' o 'deferred'
+ * @return array
+ */
+ public function getByLoadType(string $loadType): array;
+
+ /**
+ * Obtiene snippets aplicables a una página específica
+ * @param string $loadType 'critical' o 'deferred'
+ * @param string $pageType 'all', 'home', 'posts', 'pages', 'archives'
+ * @return array
+ */
+ public function getForPage(string $loadType, string $pageType): array;
+}
diff --git a/Shared/Domain/Exceptions/ValidationException.php b/Shared/Domain/Exceptions/ValidationException.php
new file mode 100644
index 00000000..ab322df5
--- /dev/null
+++ b/Shared/Domain/Exceptions/ValidationException.php
@@ -0,0 +1,50 @@
+ console.log(' -', path.relative(themeDir, f)));
- if (contentFiles.length > 5) {
- console.log(` ... and ${contentFiles.length - 5} more`);
- }
-
- try {
- const purgeCSSResult = await new PurgeCSS().purge({
- css: [inputFile],
- content: contentFiles,
-
- // Safelist: Clases que SIEMPRE deben incluirse
- safelist: {
- standard: [
- // Estados de navbar scroll (JavaScript)
- 'scrolled',
- 'navbar-scrolled',
-
- // Bootstrap Collapse (JavaScript)
- 'show',
- 'showing',
- 'hiding',
- 'collapse',
- 'collapsing',
-
- // Estados de dropdown
- 'dropdown-menu',
- 'dropdown-item',
- 'dropdown-toggle',
-
- // Estados de form
- 'is-valid',
- 'is-invalid',
- 'was-validated',
-
- // Visually hidden (accesibilidad)
- 'visually-hidden',
- 'visually-hidden-focusable',
-
- // Screen reader
- 'sr-only',
-
- // Container
- 'container',
- 'container-fluid',
-
- // Row
- 'row',
-
- // Display
- 'd-flex',
- 'd-none',
- 'd-block',
- 'd-inline-block',
- 'd-inline',
- 'd-grid',
-
- // Common spacing
- 'mb-0', 'mb-1', 'mb-2', 'mb-3', 'mb-4', 'mb-5',
- 'mt-0', 'mt-1', 'mt-2', 'mt-3', 'mt-4', 'mt-5',
- 'me-0', 'me-1', 'me-2', 'me-3', 'me-4', 'me-5',
- 'ms-0', 'ms-1', 'ms-2', 'ms-3', 'ms-4', 'ms-5',
- 'mx-auto',
- 'py-0', 'py-1', 'py-2', 'py-3', 'py-4', 'py-5',
- 'px-0', 'px-1', 'px-2', 'px-3', 'px-4', 'px-5',
- 'p-0', 'p-1', 'p-2', 'p-3', 'p-4', 'p-5',
- 'gap-0', 'gap-1', 'gap-2', 'gap-3', 'gap-4', 'gap-5',
- 'g-0', 'g-1', 'g-2', 'g-3', 'g-4', 'g-5',
-
- // Flex
- 'flex-wrap',
- 'flex-nowrap',
- 'flex-column',
- 'flex-row',
- 'justify-content-center',
- 'justify-content-between',
- 'justify-content-start',
- 'justify-content-end',
- 'align-items-center',
- 'align-items-start',
- 'align-items-end',
-
- // Text
- 'text-center',
- 'text-start',
- 'text-end',
- 'text-white',
- 'text-muted',
- 'fw-bold',
- 'fw-normal',
- 'small',
-
- // Images
- 'img-fluid',
-
- // Border/rounded
- 'rounded',
- 'rounded-circle',
- 'border',
- 'border-0',
-
- // Shadow
- 'shadow',
- 'shadow-sm',
- 'shadow-lg',
-
- // Width
- 'w-100',
- 'w-auto',
- 'h-100',
- 'h-auto',
-
- // Toast classes (plugin IP View Limit)
- 'toast-container',
- 'toast',
- 'toast-body',
- 'position-fixed',
- 'bottom-0',
- 'end-0',
- 'start-50',
- 'translate-middle-x',
- 'text-dark',
- 'bg-warning',
- 'btn-close',
- 'm-auto',
- ],
-
- deep: [
- // Grid responsive
- /^col-/,
- /^col$/,
-
- // Display responsive
- /^d-[a-z]+-/,
-
- // Navbar responsive
- /^navbar-expand/,
- /^navbar-/,
-
- // Responsive margins/padding
- /^m[tbsexy]?-[a-z]+-/,
- /^p[tbsexy]?-[a-z]+-/,
-
- // Text responsive
- /^text-[a-z]+-/,
-
- // Flex responsive
- /^flex-[a-z]+-/,
- /^justify-content-[a-z]+-/,
- /^align-items-[a-z]+-/,
- ],
-
- greedy: [
- // Form controls
- /form-/,
- /input-/,
-
- // Buttons
- /btn/,
-
- // Cards
- /card/,
-
- // Navbar
- /navbar/,
- /nav-/,
-
- // Tables
- /table/,
-
- // Alerts
- /alert/,
-
- // Toast
- /toast/,
-
- // Badges
- /badge/,
-
- // Lists
- /list-/,
- ],
- },
-
- // Mantener variables CSS de Bootstrap
- variables: true,
-
- // Mantener keyframes
- keyframes: true,
-
- // Mantener font-face
- fontFace: true,
- });
-
- if (purgeCSSResult.length === 0 || !purgeCSSResult[0].css) {
- console.error('ERROR: PurgeCSS returned empty result');
- process.exit(1);
- }
-
- // Agregar header al CSS generado
- const header = `/**
- * Bootstrap 5.3.2 Subset - ROI Theme
- *
- * Generado automáticamente con PurgeCSS
- * Contiene SOLO las clases Bootstrap usadas en el tema.
- *
- * Original: ${(inputSize / 1024).toFixed(2)} KB
- * Subset: ${(purgeCSSResult[0].css.length / 1024).toFixed(2)} KB
- * Reduccion: ${(100 - (purgeCSSResult[0].css.length / inputSize * 100)).toFixed(1)}%
- *
- * Generado: ${new Date().toISOString()}
- *
- * Para regenerar:
- * node build-bootstrap-subset.js
- */
-`;
-
- const outputCSS = header + purgeCSSResult[0].css;
-
- // Escribir archivo
- fs.writeFileSync(outputFile, outputCSS);
-
- const outputSize = fs.statSync(outputFile).size;
- const reduction = ((1 - outputSize / inputSize) * 100).toFixed(1);
-
- console.log('');
- console.log('SUCCESS!');
- console.log('-'.repeat(60));
- console.log(`Output: bootstrap-subset.min.css (${(outputSize / 1024).toFixed(2)} KB)`);
- console.log(`Reduction: ${reduction}% smaller`);
- console.log('-'.repeat(60));
- console.log('');
- console.log('Next steps:');
- console.log('1. Update Inc/enqueue-scripts.php to use bootstrap-subset.min.css');
- console.log('2. Test the theme thoroughly');
- console.log('3. Run PageSpeed Insights to verify improvement');
-
- } catch (error) {
- console.error('ERROR:', error.message);
- console.error(error.stack);
- process.exit(1);
- }
-}
-
-buildBootstrapSubset();
diff --git a/functions.php b/functions.php
index 09ab3db2..e82f376c 100644
--- a/functions.php
+++ b/functions.php
@@ -295,7 +295,43 @@ add_action('wp_footer', function() use ($container) {
}, 99); // Prioridad alta para que se renderice al final del footer
// =============================================================================
-// 5.1. INFORMACIÓN DE DEBUG (Solo en desarrollo)
+// 5.2. CUSTOM CSS MANAGER (TIPO 3: CSS Crítico Personalizado)
+// =============================================================================
+
+/**
+ * Bootstrap CustomCSSManager
+ *
+ * Inicializa el sistema de CSS personalizado configurable.
+ * - Frontend: Inyecta CSS crítico (head) y diferido (footer)
+ * - Admin: El FormBuilder se auto-registra cuando es instanciado por el dashboard
+ */
+add_action('after_setup_theme', function() {
+ // Solo inyectar CSS en frontend (no admin)
+ if (is_admin()) {
+ return;
+ }
+
+ global $wpdb;
+
+ // Repository compartido
+ $repository = new \ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository($wpdb);
+
+ // Use Cases para Public
+ $getCriticalUseCase = new \ROITheme\Public\CustomCSSManager\Application\UseCases\GetCriticalSnippetsUseCase($repository);
+ $getDeferredUseCase = new \ROITheme\Public\CustomCSSManager\Application\UseCases\GetDeferredSnippetsUseCase($repository);
+
+ // Injector de CSS en frontend
+ $injector = new \ROITheme\Public\CustomCSSManager\Infrastructure\Services\CustomCSSInjector(
+ $getCriticalUseCase,
+ $getDeferredUseCase
+ );
+
+ // Registrar hooks
+ $injector->register();
+}, 5); // Priority 5: después de theme setup básico
+
+// =============================================================================
+// 5.3. INFORMACIÓN DE DEBUG (Solo en desarrollo)
// =============================================================================
if (defined('WP_DEBUG') && WP_DEBUG) {
diff --git a/migrate-legacy-options.php b/migrate-legacy-options.php
deleted file mode 100644
index 7660fc03..00000000
--- a/migrate-legacy-options.php
+++ /dev/null
@@ -1,194 +0,0 @@
-prefix . 'roi_theme_component_settings';
- $migrated = 0;
- $skipped = 0;
- $errors = 0;
-
- // 3. Mapeo de opciones legacy → componente/grupo/atributo
- // Basado en seccion 3.4 del plan 02.02-limpieza-configuraciones-legacy.md
- $mapping = [
- // Opciones de roi_theme_options
- 'roi_adsense_delay_enabled' => [
- 'source' => 'roi_theme_options',
- 'target' => ['adsense-delay', 'visibility', 'is_enabled'],
- 'note' => 'Componente adsense-delay no existe en Admin Panel - PENDIENTE crear schema'
- ],
- 'show_category_badge' => [
- 'source' => 'roi_theme_options',
- 'target' => ['category-badge', 'visibility', 'is_enabled'],
- 'note' => 'Componente category-badge no existe en Admin Panel - PENDIENTE crear schema'
- ],
- 'toc_min_headings' => [
- 'source' => 'roi_theme_options',
- 'target' => ['table-of-contents', 'behavior', 'min_headings'],
- 'note' => 'Componente table-of-contents YA EXISTE en Admin Panel'
- ],
- 'roi_share_text' => [
- 'source' => 'roi_theme_options',
- 'target' => ['social-share', 'content', 'share_text'],
- 'note' => 'Componente social-share YA EXISTE en Admin Panel'
- ],
- 'roi_enable_share_buttons' => [
- 'source' => 'roi_theme_options',
- 'target' => ['social-share', 'visibility', 'is_enabled'],
- 'note' => 'Componente social-share YA EXISTE en Admin Panel'
- ],
- 'featured_image_single' => [
- 'source' => 'roi_theme_options',
- 'target' => ['featured-image', 'visibility', 'show_on_single'],
- 'note' => 'Componente featured-image YA EXISTE en Admin Panel'
- ],
- 'featured_image_page' => [
- 'source' => 'roi_theme_options',
- 'target' => ['featured-image', 'visibility', 'show_on_page'],
- 'note' => 'Componente featured-image YA EXISTE en Admin Panel'
- ],
- ];
-
- // Opciones que se ELIMINAN intencionalmente (no migrar)
- $intentionally_removed = [
- 'breadcrumb_separator' => 'Breadcrumbs eliminado - usar plugin si se necesita',
- 'roi_social_facebook' => 'Social links eliminados - no se usa',
- 'roi_social_twitter' => 'Social links eliminados - no se usa',
- 'roi_social_linkedin' => 'Social links eliminados - no se usa',
- 'roi_social_youtube' => 'Social links eliminados - no se usa',
- 'roi_typography_heading' => 'Typography eliminado - usar Custom CSS',
- 'roi_typography_body' => 'Typography eliminado - usar Custom CSS',
- ];
-
- $results[] = "=== Procesando Migracion ===\n\n";
-
- // 4. Procesar mapeo
- foreach ($mapping as $legacy_key => $config) {
- $source = $config['source'];
- $target = $config['target'];
- $note = $config['note'];
-
- [$component, $group, $attribute] = $target;
-
- // Obtener valor segun source
- $value = null;
- if ($source === 'roi_theme_options' && isset($legacy_options[$legacy_key])) {
- $value = $legacy_options[$legacy_key];
- } elseif ($source === 'theme_mods' && isset($theme_mods[$legacy_key])) {
- $value = $theme_mods[$legacy_key];
- }
-
- if ($value !== null) {
- // Verificar si ya existe en la nueva tabla
- $existing = $wpdb->get_var($wpdb->prepare(
- "SELECT COUNT(*) FROM {$table}
- WHERE component_name = %s AND group_name = %s AND attribute_name = %s",
- $component,
- $group,
- $attribute
- ));
-
- if ($existing > 0) {
- $results[] = "SKIP: {$legacy_key} -> Ya existe en {$component}/{$group}/{$attribute}\n";
- $results[] = " Nota: {$note}\n";
- $skipped++;
- continue;
- }
-
- // Insertar en nueva tabla
- $result = $wpdb->replace($table, [
- 'component_name' => $component,
- 'group_name' => $group,
- 'attribute_name' => $attribute,
- 'attribute_value' => is_bool($value) ? ($value ? '1' : '0') : (string)$value,
- 'is_editable' => 1,
- ]);
-
- if ($result !== false) {
- $results[] = "OK: {$legacy_key} -> {$component}/{$group}/{$attribute} = " . var_export($value, true) . "\n";
- $results[] = " Nota: {$note}\n";
- $migrated++;
- } else {
- $results[] = "ERR: {$legacy_key} -> Error al insertar: " . $wpdb->last_error . "\n";
- $errors++;
- }
- } else {
- $results[] = "SKIP: {$legacy_key} -> No encontrado en {$source}\n";
- $skipped++;
- }
- }
-
- // 5. Documentar opciones eliminadas intencionalmente
- $results[] = "\n=== Opciones Eliminadas Intencionalmente ===\n\n";
- foreach ($intentionally_removed as $key => $reason) {
- if (isset($legacy_options[$key])) {
- $results[] = "DEL: {$key} -> {$reason}\n";
- }
- }
-
- // 6. Resumen
- $results[] = "\n=== Resumen ===\n";
- $results[] = "Migradas: {$migrated}\n";
- $results[] = "Omitidas: {$skipped}\n";
- $results[] = "Errores: {$errors}\n";
- $results[] = "\n=== Fin de Migracion ===\n";
-
- $output = implode('', $results);
-
- // Guardar log
- $log_file = get_template_directory() . '/migrate-legacy-options.log';
- file_put_contents($log_file, $output);
-
- return $output;
-}
-
-// Ejecutar si se llama directamente via WP-CLI
-if (defined('WP_CLI') || (defined('ABSPATH') && php_sapi_name() === 'cli')) {
- echo roi_migrate_legacy_options();
-}
diff --git a/minify-css.php b/minify-css.php
deleted file mode 100644
index 18996c02..00000000
--- a/minify-css.php
+++ /dev/null
@@ -1,60 +0,0 @@
-+~])\s*/', '$1', $css);
-
- // Remove last semicolon before closing brace
- $css = str_replace(';}', '}', $css);
-
- // Trim
- $css = trim($css);
-
- return $css;
-}
-
-$files = [
- 'Assets/Css/css-global-accessibility.css' => 'Assets/Css/css-global-accessibility.min.css',
- 'Assets/Css/style.css' => 'Assets/Css/style.min.css',
-];
-
-$base_path = __DIR__ . '/';
-
-foreach ($files as $source => $dest) {
- $source_path = $base_path . $source;
- $dest_path = $base_path . $dest;
-
- if (file_exists($source_path)) {
- $css = file_get_contents($source_path);
- $minified = minify_css($css);
-
- file_put_contents($dest_path, $minified);
-
- $original_size = strlen($css);
- $minified_size = strlen($minified);
- $savings = $original_size - $minified_size;
- $percent = round(($savings / $original_size) * 100, 1);
-
- echo "Minified: $source\n";
- echo " Original: " . number_format($original_size) . " bytes\n";
- echo " Minified: " . number_format($minified_size) . " bytes\n";
- echo " Savings: " . number_format($savings) . " bytes ($percent%)\n\n";
- } else {
- echo "File not found: $source\n";
- }
-}
-
-echo "Done!\n";
diff --git a/purgecss.config.js b/purgecss.config.js
deleted file mode 100644
index 6a4151f4..00000000
--- a/purgecss.config.js
+++ /dev/null
@@ -1,174 +0,0 @@
-/**
- * PurgeCSS Configuration for ROI Theme
- *
- * Genera un subset de Bootstrap con SOLO las clases usadas en el tema.
- *
- * USO:
- * npx purgecss --config purgecss.config.js
- *
- * OUTPUT:
- * Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css
- *
- * @see https://purgecss.com/configuration.html
- */
-
-module.exports = {
- // CSS a procesar
- css: ['Assets/Vendor/Bootstrap/Css/bootstrap.min.css'],
-
- // Archivos a analizar para encontrar clases usadas
- content: [
- // Templates PHP principales
- '*.php',
-
- // Componentes Public (Renderers)
- 'Public/**/*.php',
-
- // Componentes Admin (FormBuilders) - también usan Bootstrap
- 'Admin/**/*.php',
-
- // Includes
- 'Inc/**/*.php',
-
- // Templates parts
- 'template-parts/**/*.php',
-
- // JavaScript (puede contener clases dinámicas)
- 'Assets/js/**/*.js',
- ],
-
- // Output
- output: 'Assets/Vendor/Bootstrap/Css/',
-
- // Safelist: Clases que SIEMPRE deben incluirse aunque no se detecten
- // (clases generadas dinámicamente, JavaScript, etc.)
- safelist: {
- // Clases exactas
- standard: [
- // Estados de navbar scroll (JavaScript)
- 'scrolled',
- 'navbar-scrolled',
-
- // Bootstrap Collapse (JavaScript)
- 'show',
- 'showing',
- 'hiding',
- 'collapse',
- 'collapsing',
-
- // Estados de dropdown
- 'dropdown-menu',
- 'dropdown-item',
- 'dropdown-toggle',
-
- // Estados de form
- 'is-valid',
- 'is-invalid',
- 'was-validated',
-
- // Visually hidden (accesibilidad)
- 'visually-hidden',
- 'visually-hidden-focusable',
-
- // Screen reader
- 'sr-only',
- ],
-
- // Patrones regex
- deep: [
- // Todas las variantes de col-* (grid responsive)
- /^col-/,
-
- // Todas las variantes de d-* (display)
- /^d-/,
-
- // Todas las variantes responsive de navbar
- /^navbar-expand/,
-
- // Margin/Padding responsive
- /^m[tbsexy]?-/,
- /^p[tbsexy]?-/,
-
- // Gap utilities
- /^gap-/,
- /^g-/,
-
- // Flex utilities
- /^flex-/,
- /^justify-/,
- /^align-/,
-
- // Text utilities
- /^text-/,
- /^fw-/,
- /^fs-/,
-
- // Background
- /^bg-/,
-
- // Border
- /^border/,
- /^rounded/,
-
- // Shadow
- /^shadow/,
-
- // Width/Height
- /^w-/,
- /^h-/,
-
- // Position
- /^position-/,
- /^top-/,
- /^bottom-/,
- /^start-/,
- /^end-/,
-
- // Overflow
- /^overflow-/,
- ],
-
- // Selectores con estos patrones en cualquier parte
- greedy: [
- // Form controls
- /form-/,
-
- // Buttons
- /btn/,
-
- // Cards
- /card/,
-
- // Navbar
- /navbar/,
- /nav-/,
-
- // Tables
- /table/,
-
- // Alerts (usado en admin)
- /alert/,
-
- // Toast (consultas restantes)
- /toast/,
-
- // Badges
- /badge/,
-
- // Lists
- /list-/,
- ],
- },
-
- // Variables CSS de Bootstrap (mantener todas)
- variables: true,
-
- // Keyframes de animaciones
- keyframes: true,
-
- // Font faces
- fontFace: true,
-
- // Rejected (para debugging - genera archivo con clases eliminadas)
- rejected: false,
-};