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 .= '

Gestor de CSS Personalizado

'; + $html .= ' ' . esc_html($snippetCount) . ' snippet(s)'; + $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 .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + $html .= ' '; + foreach ($snippets as $snippet) { + $html .= $this->renderSnippetRow($snippet); + } + $html .= ' '; + $html .= '
NombreTipoPáginasEstadoAcciones
'; + $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 .= ' '; + $html .= ' '; + + $html .= '
'; + + // Nombre + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + // Tipo + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + // Orden + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + // Descripción + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + // Páginas + $html .= '
'; + $html .= ' '; + $html .= '
'; + foreach ($this->getPageOptions() as $value => $label) { + $checked = $value === 'all' ? 'checked' : ''; + $html .= sprintf( + '
', + esc_attr($value), + esc_attr($value), + $checked, + esc_attr($value), + esc_html($label) + ); + } + $html .= '
'; + $html .= '
'; + + // Estado + $html .= '
'; + $html .= ' '; + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + $html .= '
'; + + // Código CSS + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= ' Crítico: máx 14KB | Diferido: máx 100KB'; + $html .= '
'; + + // Botones + $html .= '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + $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, -};