feat(custom-css-manager): implementar TIPO 3 - CSS Crítico Personalizado

Nuevo sistema de gestión de CSS personalizado con panel admin:
- Admin/CustomCSSManager: CRUD de snippets CSS (crítico/diferido)
- Public/CustomCSSManager: Inyección dinámica en frontend
- Schema JSON para configuración del componente

Migración de CSS estático a BD:
- Tablas APU (~14KB) → snippet diferido en BD
- Tablas Genéricas (~10KB) → snippet diferido en BD
- Comentadas funciones legacy en enqueue-scripts.php

Limpieza de archivos obsoletos:
- Eliminado build-bootstrap-subset.js
- Eliminado migrate-legacy-options.php
- Eliminado minify-css.php
- Eliminado purgecss.config.js

Beneficios:
- CSS editable desde admin sin tocar código
- Soporte crítico (head) y diferido (footer)
- Filtrado por scope (all/home/single/archive)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-01 15:43:25 -06:00
parent 423aae062c
commit 9cb0dd1491
24 changed files with 1553 additions and 784 deletions

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Application\DTOs;
/**
* DTO para solicitud de guardado de snippet
*
* Inmutable - una vez creado no puede modificarse.
* Transporta datos desde Infrastructure (form) hacia Application (use case).
*/
final class SaveSnippetRequest
{
/**
* @param string $id ID único del snippet (nuevo o existente)
* @param string $name Nombre descriptivo
* @param string $description Descripción opcional
* @param string $css Código CSS
* @param string $type Tipo de carga: 'critical' | 'deferred'
* @param array<string> $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,
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Application\UseCases;
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
/**
* Caso de uso: Eliminar snippet CSS
*
* SRP: Solo responsable de orquestar la eliminación
*/
final class DeleteSnippetUseCase
{
public function __construct(
private readonly CSSSnippetRepositoryInterface $repository
) {}
/**
* Ejecuta la eliminación del snippet
*
* @param string $snippetId ID del snippet a eliminar
* @return void
*/
public function execute(string $snippetId): void
{
$this->repository->delete($snippetId);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Application\UseCases;
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
/**
* Caso de uso: Obtener todos los snippets (para Admin UI)
*
* SRP: Solo responsable de obtener lista completa
*/
final class GetAllSnippetsUseCase
{
public function __construct(
private readonly CSSSnippetRepositoryInterface $repository
) {}
/**
* Ejecuta la obtención de todos los snippets
*
* @return array<array> Lista de snippets ordenados por 'order'
*/
public function execute(): array
{
return $this->repository->getAll();
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Application\UseCases;
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
use ROITheme\Admin\CustomCSSManager\Domain\Entities\CSSSnippet;
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
/**
* Caso de uso: Guardar snippet CSS
*
* SRP: Solo responsable de orquestar el guardado
*/
final class SaveSnippetUseCase
{
public function __construct(
private readonly CSSSnippetRepositoryInterface $repository
) {}
public function execute(SaveSnippetRequest $request): void
{
// 1. Crear entidad desde DTO
$snippet = CSSSnippet::fromArray($request->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());
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Domain\Entities;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\CSSCode;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\LoadType;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Entidad de dominio para snippet CSS (contexto Admin)
*
* Responsabilidad: Reglas de negocio para ADMINISTRAR snippets
*/
final class CSSSnippet
{
private function __construct(
private readonly SnippetId $id,
private readonly string $name,
private readonly string $description,
private readonly CSSCode $css,
private readonly LoadType $loadType,
private readonly array $pages,
private readonly bool $enabled,
private readonly int $order
) {}
/**
* Factory method desde array (BD)
*/
public static function fromArray(array $data): self
{
return new self(
SnippetId::fromString($data['id']),
$data['name'],
$data['description'] ?? '',
CSSCode::fromString($data['css']),
LoadType::fromString($data['type']),
$data['pages'] ?? ['all'],
$data['enabled'] ?? true,
$data['order'] ?? 100
);
}
/**
* Valida que el snippet pueda ser guardado
* @throws ValidationException
*/
public function validate(): void
{
if (empty($this->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;
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Value Object para código CSS validado
*/
final class CSSCode
{
private const MAX_SIZE_CRITICAL = 14336; // 14KB para CSS crítico
private const MAX_SIZE_DEFERRED = 102400; // 100KB para CSS diferido
private function __construct(
private readonly string $value
) {}
public static function fromString(string $css): self
{
$sanitized = self::sanitize($css);
self::validate($sanitized);
return new self($sanitized);
}
private static function sanitize(string $css): string
{
// Eliminar etiquetas <style>
$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);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Value Object para tipo de carga CSS
*/
final class LoadType
{
private const VALID_TYPES = ['critical', 'deferred'];
private function __construct(
private readonly string $value
) {}
public static function fromString(string $value): self
{
if (!in_array($value, self::VALID_TYPES, true)) {
throw new ValidationException(
sprintf('LoadType inválido: %s. Valores válidos: %s',
$value,
implode(', ', self::VALID_TYPES)
)
);
}
return new self($value);
}
public static function critical(): self
{
return new self('critical');
}
public static function deferred(): self
{
return new self('deferred');
}
public function isCritical(): bool
{
return $this->value === 'critical';
}
public function isDeferred(): bool
{
return $this->value === 'deferred';
}
public function value(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* Value Object para ID único de snippet CSS
*
* Soporta dos formatos:
* 1. Generado: css_[timestamp]_[random] (ej: "css_1701432000_a1b2c3")
* 2. Legacy/Migración: kebab-case (ej: "cls-tables-apu", "generic-tables")
*
* Esto permite migrar snippets existentes sin romper IDs.
*/
final class SnippetId
{
private const PREFIX = 'css_';
private const PATTERN_GENERATED = '/^css_[0-9]+_[a-z0-9]{6}$/';
private const PATTERN_LEGACY = '/^[a-z0-9]+(-[a-z0-9]+)*$/';
private function __construct(
private readonly string $value
) {}
/**
* Crea SnippetId desde string existente (desde BD)
*
* Acepta tanto IDs generados (css_*) como IDs legacy (kebab-case).
*
* @param string $id ID existente
* @return self
* @throws ValidationException Si el formato es inválido
*/
public static function fromString(string $id): self
{
$id = trim($id);
if (empty($id)) {
throw new ValidationException('El ID del snippet no puede estar vacío');
}
if (strlen($id) > 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;
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence;
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
/**
* Repositorio WordPress para snippets CSS
*
* Almacena snippets como JSON en wp_roi_theme_component_settings
*/
final class WordPressSnippetRepository implements CSSSnippetRepositoryInterface
{
private const COMPONENT_NAME = 'custom-css-manager';
private const GROUP_NAME = 'css_snippets';
private const ATTRIBUTE_NAME = 'snippets_json';
public function __construct(
private readonly \wpdb $wpdb
) {}
public function getAll(): array
{
$tableName = $this->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']
);
}
}
}

View File

@@ -0,0 +1,462 @@
<?php
declare(strict_types=1);
namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Ui;
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\SaveSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\DeleteSnippetUseCase;
use ROITheme\Admin\CustomCSSManager\Application\UseCases\GetAllSnippetsUseCase;
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
use ROITheme\Shared\Domain\Exceptions\ValidationException;
/**
* FormBuilder para gestión de CSS snippets en Admin Panel
*
* Sigue el patrón estándar de FormBuilders del tema:
* - Constructor recibe AdminDashboardRenderer
* - Método buildForm() genera el HTML del formulario
*
* Design System: Gradiente navy #0E2337 → #1e3a5f, accent #FF8600
*/
final class CustomCSSManagerFormBuilder
{
private const COMPONENT_ID = 'custom-css-manager';
private const NONCE_ACTION = 'roi_custom_css_manager';
private WordPressSnippetRepository $repository;
private GetAllSnippetsUseCase $getAllUseCase;
private SaveSnippetUseCase $saveUseCase;
private DeleteSnippetUseCase $deleteUseCase;
public function __construct(
private readonly AdminDashboardRenderer $renderer
) {
// Crear repositorio y Use Cases internamente
global $wpdb;
$this->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(
'<div class="alert alert-%s m-3">%s</div>',
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 = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-header d-flex justify-content-between align-items-center" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%);">';
$html .= ' <h3 class="mb-0 text-white"><i class="bi bi-file-earmark-code me-2"></i>Gestor de CSS Personalizado</h3>';
$html .= ' <span class="badge bg-light text-dark">' . esc_html($snippetCount) . ' snippet(s)</span>';
$html .= ' </div>';
$html .= ' <div class="card-body">';
$html .= ' <p class="text-muted mb-0">Gestiona snippets de CSS personalizados. Los snippets críticos se cargan en el head, los diferidos en el footer.</p>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Construye la lista de snippets existentes
*/
private function buildSnippetsList(array $snippets): string
{
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-list-ul me-2" style="color: #FF8600;"></i>';
$html .= ' Snippets Configurados';
$html .= ' </h5>';
if (empty($snippets)) {
$html .= ' <p class="text-muted">No hay snippets configurados.</p>';
} else {
$html .= ' <div class="table-responsive">';
$html .= ' <table class="table table-hover">';
$html .= ' <thead>';
$html .= ' <tr>';
$html .= ' <th>Nombre</th>';
$html .= ' <th>Tipo</th>';
$html .= ' <th>Páginas</th>';
$html .= ' <th>Estado</th>';
$html .= ' <th>Acciones</th>';
$html .= ' </tr>';
$html .= ' </thead>';
$html .= ' <tbody>';
foreach ($snippets as $snippet) {
$html .= $this->renderSnippetRow($snippet);
}
$html .= ' </tbody>';
$html .= ' </table>';
$html .= ' </div>';
}
$html .= ' </div>';
$html .= '</div>';
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 <<<HTML
<tr>
<td><strong>{$name}</strong></td>
<td><span class="badge {$typeBadge}">{$type}</span></td>
<td><small>{$pages}</small></td>
<td><span class="badge {$enabledBadge}">{$enabled}</span></td>
<td>
<button type="button" class="btn btn-sm btn-outline-primary btn-edit-snippet"
data-snippet="{$snippetJson}">
<i class="bi bi-pencil"></i>
</button>
<form method="post" class="d-inline"
onsubmit="return confirm('¿Eliminar este snippet?');">
<input type="hidden" name="_wpnonce" value="{$nonce}">
<input type="hidden" name="roi_css_action" value="delete">
<input type="hidden" name="snippet_id" value="{$id}">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
HTML;
}
/**
* Construye el formulario de creación/edición de snippets
*/
private function buildSnippetForm(): string
{
$nonce = wp_create_nonce(self::NONCE_ACTION);
$html = '<div class="card shadow-sm mb-4" style="border-left: 4px solid #FF8600;">';
$html .= ' <div class="card-body">';
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
$html .= ' <i class="bi bi-plus-circle me-2" style="color: #FF8600;"></i>';
$html .= ' Agregar/Editar Snippet';
$html .= ' </h5>';
$html .= ' <form method="post" id="roi-snippet-form">';
$html .= ' <input type="hidden" name="_wpnonce" value="' . esc_attr($nonce) . '">';
$html .= ' <input type="hidden" name="roi_css_action" value="save">';
$html .= ' <input type="hidden" name="snippet_id" id="snippet_id" value="">';
$html .= ' <div class="row g-3">';
// Nombre
$html .= ' <div class="col-md-6">';
$html .= ' <label class="form-label">Nombre *</label>';
$html .= ' <input type="text" name="snippet_name" id="snippet_name" class="form-control" required maxlength="100" placeholder="Ej: Estilos Tablas APU">';
$html .= ' </div>';
// Tipo
$html .= ' <div class="col-md-3">';
$html .= ' <label class="form-label">Tipo *</label>';
$html .= ' <select name="snippet_type" id="snippet_type" class="form-select">';
$html .= ' <option value="critical">Crítico (head)</option>';
$html .= ' <option value="deferred" selected>Diferido (footer)</option>';
$html .= ' </select>';
$html .= ' </div>';
// Orden
$html .= ' <div class="col-md-3">';
$html .= ' <label class="form-label">Orden</label>';
$html .= ' <input type="number" name="snippet_order" id="snippet_order" class="form-control" value="100" min="1" max="999">';
$html .= ' </div>';
// Descripción
$html .= ' <div class="col-12">';
$html .= ' <label class="form-label">Descripción</label>';
$html .= ' <input type="text" name="snippet_description" id="snippet_description" class="form-control" maxlength="255" placeholder="Descripción breve del propósito del CSS">';
$html .= ' </div>';
// Páginas
$html .= ' <div class="col-md-6">';
$html .= ' <label class="form-label">Aplicar en páginas</label>';
$html .= ' <div class="d-flex flex-wrap gap-2">';
foreach ($this->getPageOptions() as $value => $label) {
$checked = $value === 'all' ? 'checked' : '';
$html .= sprintf(
'<div class="form-check"><input type="checkbox" name="snippet_pages[]" value="%s" id="page_%s" class="form-check-input" %s><label class="form-check-label" for="page_%s">%s</label></div>',
esc_attr($value),
esc_attr($value),
$checked,
esc_attr($value),
esc_html($label)
);
}
$html .= ' </div>';
$html .= ' </div>';
// Estado
$html .= ' <div class="col-md-6">';
$html .= ' <label class="form-label">Estado</label>';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input type="checkbox" name="snippet_enabled" id="snippet_enabled" class="form-check-input" checked>';
$html .= ' <label class="form-check-label" for="snippet_enabled">Habilitado</label>';
$html .= ' </div>';
$html .= ' </div>';
// Código CSS
$html .= ' <div class="col-12">';
$html .= ' <label class="form-label">Código CSS *</label>';
$html .= ' <textarea name="snippet_css" id="snippet_css" class="form-control font-monospace" rows="10" required placeholder="/* Tu CSS aquí */"></textarea>';
$html .= ' <small class="text-muted">Crítico: máx 14KB | Diferido: máx 100KB</small>';
$html .= ' </div>';
// Botones
$html .= ' <div class="col-12">';
$html .= ' <button type="submit" class="btn text-white" style="background-color: #FF8600;">';
$html .= ' <i class="bi bi-save me-1"></i> Guardar Snippet';
$html .= ' </button>';
$html .= ' <button type="button" class="btn btn-secondary" onclick="resetCssForm()">';
$html .= ' <i class="bi bi-x-circle me-1"></i> Cancelar';
$html .= ' </button>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </form>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
/**
* Genera el JavaScript necesario para el formulario
*/
private function buildJavaScript(): string
{
return <<<JS
<script>
// 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 = '';
}
</script>
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;
}
}