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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
115
Admin/CustomCSSManager/Domain/Entities/CSSSnippet.php
Normal file
115
Admin/CustomCSSManager/Domain/Entities/CSSSnippet.php
Normal 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;
|
||||
}
|
||||
}
|
||||
74
Admin/CustomCSSManager/Domain/ValueObjects/CSSCode.php
Normal file
74
Admin/CustomCSSManager/Domain/ValueObjects/CSSCode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
56
Admin/CustomCSSManager/Domain/ValueObjects/LoadType.php
Normal file
56
Admin/CustomCSSManager/Domain/ValueObjects/LoadType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
121
Admin/CustomCSSManager/Domain/ValueObjects/SnippetId.php
Normal file
121
Admin/CustomCSSManager/Domain/ValueObjects/SnippetId.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// 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
|
||||
|
||||
@@ -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']
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -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 .= '<noscript><link rel="stylesheet" href="' . esc_url($noscript_url) . '"></noscript>';
|
||||
}
|
||||
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 .= '<noscript><link rel="stylesheet" href="' . esc_url($noscript_url) . '"></noscript>';
|
||||
// }
|
||||
// return $tag;
|
||||
// }
|
||||
// add_filter('style_loader_tag', 'roi_async_apu_tables_css_tag', 10, 2);
|
||||
|
||||
/**
|
||||
* Enqueue APU Tables auto-class JavaScript
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Public\CustomCSSManager\Application\UseCases;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Caso de uso: Obtener snippets críticos para página actual
|
||||
*
|
||||
* Contexto: Public (renderizado)
|
||||
*/
|
||||
final class GetCriticalSnippetsUseCase
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CSSSnippetRepositoryInterface $repository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string $pageType Tipo de página actual
|
||||
* @return array<array> Snippets críticos aplicables
|
||||
*/
|
||||
public function execute(string $pageType): array
|
||||
{
|
||||
return $this->repository->getForPage('critical', $pageType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Public\CustomCSSManager\Application\UseCases;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\CSSSnippetRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Caso de uso: Obtener snippets diferidos para página actual
|
||||
*
|
||||
* Contexto: Public (renderizado)
|
||||
* Se ejecuta en wp_footer para cargar CSS no crítico.
|
||||
*/
|
||||
final class GetDeferredSnippetsUseCase
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CSSSnippetRepositoryInterface $repository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ejecuta la obtención de snippets diferidos
|
||||
*
|
||||
* @param string $pageType Tipo de página actual ('home', 'posts', 'pages', 'archives', 'all')
|
||||
* @return array<array> Snippets diferidos aplicables a la página
|
||||
*/
|
||||
public function execute(string $pageType): array
|
||||
{
|
||||
return $this->repository->getForPage('deferred', $pageType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Public\CustomCSSManager\Infrastructure\Services;
|
||||
|
||||
use ROITheme\Public\CustomCSSManager\Application\UseCases\GetCriticalSnippetsUseCase;
|
||||
use ROITheme\Public\CustomCSSManager\Application\UseCases\GetDeferredSnippetsUseCase;
|
||||
|
||||
/**
|
||||
* Servicio que inyecta CSS en wp_head y wp_footer
|
||||
*
|
||||
* NO lee archivos CSS físicos - todo viene de BD
|
||||
*/
|
||||
final class CustomCSSInjector
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GetCriticalSnippetsUseCase $getCriticalUseCase,
|
||||
private readonly GetDeferredSnippetsUseCase $getDeferredUseCase
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registra hooks de WordPress
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// CSS crítico: priority 2 (después de componentes, antes de theme-settings)
|
||||
add_action('wp_head', [$this, 'injectCriticalCSS'], 2);
|
||||
|
||||
// CSS diferido: priority alta en footer
|
||||
add_action('wp_footer', [$this, 'injectDeferredCSS'], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inyecta CSS crítico inline en <head>
|
||||
*/
|
||||
public function injectCriticalCSS(): void
|
||||
{
|
||||
$pageType = $this->getCurrentPageType();
|
||||
$snippets = $this->getCriticalUseCase->execute($pageType);
|
||||
|
||||
if (empty($snippets)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$css = $this->combineSnippets($snippets);
|
||||
|
||||
if (!empty($css)) {
|
||||
printf(
|
||||
'<style id="roi-custom-critical-css">%s</style>' . "\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(
|
||||
'<style id="roi-custom-deferred-css">%s</style>' . "\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';
|
||||
}
|
||||
}
|
||||
20
Schemas/custom-css-manager.json
Normal file
20
Schemas/custom-css-manager.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
Shared/Domain/Contracts/CSSSnippetRepositoryInterface.php
Normal file
45
Shared/Domain/Contracts/CSSSnippetRepositoryInterface.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
/**
|
||||
* Contrato para repositorio de snippets CSS
|
||||
*
|
||||
* Usado por Admin (CRUD) y Public (lectura)
|
||||
*/
|
||||
interface CSSSnippetRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Obtiene todos los snippets almacenados
|
||||
* @return array<array> 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<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<array>
|
||||
*/
|
||||
public function getForPage(string $loadType, string $pageType): array;
|
||||
}
|
||||
50
Shared/Domain/Exceptions/ValidationException.php
Normal file
50
Shared/Domain/Exceptions/ValidationException.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Exceptions;
|
||||
|
||||
/**
|
||||
* Excepción de dominio para errores de validación
|
||||
*
|
||||
* Usada cuando las reglas de negocio no se cumplen.
|
||||
* Ubicada en Shared porque es usada por múltiples bounded contexts.
|
||||
*/
|
||||
final class ValidationException extends \DomainException
|
||||
{
|
||||
/**
|
||||
* @param string $message Mensaje descriptivo del error de validación
|
||||
* @param int $code Código de error (default 0)
|
||||
* @param \Throwable|null $previous Excepción anterior para encadenamiento
|
||||
*/
|
||||
public function __construct(
|
||||
string $message,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: Campo requerido faltante
|
||||
*/
|
||||
public static function requiredField(string $fieldName): self
|
||||
{
|
||||
return new self("El campo '{$fieldName}' es requerido");
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: Valor inválido
|
||||
*/
|
||||
public static function invalidValue(string $fieldName, string $reason): self
|
||||
{
|
||||
return new self("Valor inválido para '{$fieldName}': {$reason}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory: Longitud excedida
|
||||
*/
|
||||
public static function maxLengthExceeded(string $fieldName, int $maxLength): self
|
||||
{
|
||||
return new self("El campo '{$fieldName}' excede el máximo de {$maxLength} caracteres");
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
/**
|
||||
* Build Bootstrap Subset Script
|
||||
*
|
||||
* Genera un subset de Bootstrap con SOLO las clases usadas en el tema.
|
||||
*
|
||||
* USO:
|
||||
* node build-bootstrap-subset.js
|
||||
*
|
||||
* OUTPUT:
|
||||
* Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css
|
||||
*/
|
||||
|
||||
const { PurgeCSS } = require('purgecss');
|
||||
const { globSync } = require('glob');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function buildBootstrapSubset() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('Building Bootstrap Subset for ROI Theme');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const themeDir = __dirname;
|
||||
const inputFile = path.join(themeDir, 'Assets/Vendor/Bootstrap/Css/bootstrap.min.css');
|
||||
const outputFile = path.join(themeDir, 'Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css');
|
||||
|
||||
// Verificar que existe el archivo de entrada
|
||||
if (!fs.existsSync(inputFile)) {
|
||||
console.error('ERROR: bootstrap.min.css not found at:', inputFile);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputSize = fs.statSync(inputFile).size;
|
||||
console.log(`Input: bootstrap.min.css (${(inputSize / 1024).toFixed(2)} KB)`);
|
||||
|
||||
// Encontrar archivos PHP y JS manualmente
|
||||
console.log('\nScanning for PHP and JS files...');
|
||||
|
||||
const patterns = [
|
||||
'*.php',
|
||||
'Public/**/*.php',
|
||||
'Admin/**/*.php',
|
||||
'Inc/**/*.php',
|
||||
'Shared/**/*.php',
|
||||
'template-parts/**/*.php',
|
||||
'Assets/js/**/*.js',
|
||||
];
|
||||
|
||||
let contentFiles = [];
|
||||
for (const pattern of patterns) {
|
||||
const files = globSync(pattern, { cwd: themeDir, absolute: true });
|
||||
contentFiles = contentFiles.concat(files);
|
||||
}
|
||||
|
||||
console.log(`Found ${contentFiles.length} files to analyze`);
|
||||
|
||||
if (contentFiles.length === 0) {
|
||||
console.error('ERROR: No content files found. Check glob patterns.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Mostrar algunos archivos encontrados
|
||||
console.log('\nSample files:');
|
||||
contentFiles.slice(0, 5).forEach(f => 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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Script de Migracion de Opciones Legacy
|
||||
*
|
||||
* Migra configuraciones de wp_options (roi_theme_options) a la tabla
|
||||
* wp_roi_theme_component_settings (Clean Architecture).
|
||||
*
|
||||
* IMPORTANTE:
|
||||
* - Ejecutar UNA SOLA VEZ
|
||||
* - Hacer BACKUP de wp_options ANTES de ejecutar
|
||||
* - Ejecutar via WP-CLI: wp eval-file wp-content/themes/roi-theme/migrate-legacy-options.php
|
||||
*
|
||||
* @package ROITheme
|
||||
* @version 1.0.0
|
||||
* @date 2025-01-26
|
||||
*/
|
||||
|
||||
// Prevenir acceso directo
|
||||
if (!defined('ABSPATH')) {
|
||||
// Si se ejecuta via WP-CLI, ABSPATH estara definido
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
exit('Acceso directo no permitido.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrar configuraciones legacy a nueva tabla
|
||||
*
|
||||
* @return string Mensaje de resultado
|
||||
*/
|
||||
function roi_migrate_legacy_options(): string {
|
||||
global $wpdb;
|
||||
|
||||
$results = [];
|
||||
$results[] = "=== ROI Theme: Migracion de Opciones Legacy ===\n";
|
||||
$results[] = "Fecha: " . date('Y-m-d H:i:s') . "\n\n";
|
||||
|
||||
// 1. Obtener opciones legacy
|
||||
$legacy_options = get_option('roi_theme_options', []);
|
||||
|
||||
if (empty($legacy_options)) {
|
||||
$results[] = "INFO: No se encontraron opciones legacy en 'roi_theme_options'.\n";
|
||||
$results[] = " Esto es normal si el sitio ya usa Clean Architecture.\n";
|
||||
} else {
|
||||
$results[] = "INFO: Encontradas " . count($legacy_options) . " opciones legacy.\n\n";
|
||||
}
|
||||
|
||||
// 2. Obtener theme_mods
|
||||
$theme_mods = get_theme_mods();
|
||||
$results[] = "INFO: Theme mods encontrados: " . count($theme_mods) . "\n\n";
|
||||
|
||||
$table = $wpdb->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();
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Simple CSS Minifier Script
|
||||
* Run from command line: php minify-css.php
|
||||
*/
|
||||
|
||||
function minify_css($css) {
|
||||
// Remove comments
|
||||
$css = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $css);
|
||||
|
||||
// Remove space after colons
|
||||
$css = str_replace(': ', ':', $css);
|
||||
|
||||
// Remove whitespace
|
||||
$css = str_replace(array("\r\n", "\r", "\n", "\t", ' ', ' ', ' '), '', $css);
|
||||
|
||||
// Remove space before and after specific characters
|
||||
$css = preg_replace('/\s*([{};,>+~])\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";
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user