feat(pagespeed): implementar campo is_critical para CSS crítico dinámico (Phase 4.2)

Implementación completa del sistema de Critical CSS dinámico según plan 13.01:

Domain Layer:
- Crear CriticalCSSCollectorInterface para DIP compliance

Infrastructure Layer:
- Implementar CriticalCSSCollector (singleton via DIContainer)
- Crear CriticalCSSHooksRegistrar para inyección en wp_head
- Actualizar DIContainer con getCriticalCSSCollector()

Schemas:
- Agregar campo is_critical a navbar, top-notification-bar, hero
- Sincronizar con BD (18+39+31 campos)

Renderers (navbar, top-notification-bar, hero):
- Inyectar CriticalCSSCollectorInterface via constructor
- Lógica condicional: si is_critical=true → CSS a <head>

Admin (FormBuilders + FieldMappers):
- Toggle "CSS Crítico" en sección visibility
- Mapeo AJAX para persistencia

Beneficios:
- LCP optimizado: CSS crítico inline en <head>
- Above-the-fold rendering sin FOUC
- Componentes configurables desde admin panel

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-11-29 09:29:45 -06:00
parent 6d03076032
commit 4f25297f14
17 changed files with 340 additions and 15 deletions

View File

@@ -27,6 +27,7 @@ final class HeroFieldMapper implements FieldMapperInterface
'heroShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'heroShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'heroShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'heroIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Content
'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],

View File

@@ -103,7 +103,7 @@ final class HeroFormBuilder
$html .= ' </div>';
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <div class="mb-2 mt-3">';
$html .= ' <label for="heroShowOnPages" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
@@ -116,6 +116,20 @@ final class HeroFormBuilder
$html .= ' </select>';
$html .= ' </div>';
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="heroIsCritical" ';
$html .= checked($isCritical, true, false) . '>';
$html .= ' <label class="form-check-label small" for="heroIsCritical">';
$html .= ' <i class="bi bi-lightning-charge me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>CSS Crítico</strong>';
$html .= ' <small class="text-muted d-block">Inyectar CSS en &lt;head&gt; para optimizar LCP</small>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';

View File

@@ -28,6 +28,7 @@ final class NavbarFieldMapper implements FieldMapperInterface
'navbarShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'navbarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'navbarSticky' => ['group' => 'visibility', 'attribute' => 'sticky_enabled'],
'navbarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Layout
'navbarContainerType' => ['group' => 'layout', 'attribute' => 'container_type'],

View File

@@ -119,7 +119,7 @@ final class NavbarFormBuilder
// Switch: Sticky
$sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true);
$html .= ' <div class="mb-0">';
$html .= ' <div class="mb-2">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarSticky" name="visibility[sticky_enabled]" ';
$html .= checked($sticky, true, false) . '>';
@@ -129,6 +129,19 @@ final class NavbarFormBuilder
$html .= ' </div>';
$html .= ' </div>';
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-0">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="navbarIsCritical" name="visibility[is_critical]" ';
$html .= checked($isCritical, true, false) . '>';
$html .= ' <label class="form-check-label small" for="navbarIsCritical">';
$html .= ' <strong>CSS Crítico</strong>';
$html .= ' <small class="text-muted d-block">Inyectar CSS en &lt;head&gt; para optimizar LCP</small>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';

View File

@@ -27,6 +27,7 @@ final class TopNotificationBarFieldMapper implements FieldMapperInterface
'topBarShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
'topBarShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
'topBarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
'topBarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
// Content
'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'],

View File

@@ -107,7 +107,7 @@ final class TopNotificationBarFormBuilder
// Select: Show on Pages
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <div class="mb-2 mt-3">';
$html .= ' <label for="topBarShowOnPages" class="form-label small mb-1 fw-semibold" style="color: #495057;">';
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
$html .= ' Mostrar en';
@@ -120,6 +120,20 @@ final class TopNotificationBarFormBuilder
$html .= ' </select>';
$html .= ' </div>';
// Switch: CSS Crítico
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
$html .= ' <div class="mb-0 mt-3">';
$html .= ' <div class="form-check form-switch">';
$html .= ' <input class="form-check-input" type="checkbox" id="topBarIsCritical" ';
$html .= checked($isCritical, true, false) . '>';
$html .= ' <label class="form-check-label small" for="topBarIsCritical" style="color: #495057;">';
$html .= ' <i class="bi bi-lightning-charge me-1" style="color: #FF8600;"></i>';
$html .= ' <strong>CSS Crítico</strong>';
$html .= ' <small class="text-muted d-block">Inyectar CSS en &lt;head&gt; para optimizar LCP</small>';
$html .= ' </label>';
$html .= ' </div>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';

View File

@@ -5,6 +5,7 @@ namespace ROITheme\Public\Hero\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Contracts\CriticalCSSCollectorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
@@ -28,8 +29,13 @@ use ROITheme\Shared\Domain\Entities\Component;
*/
final class HeroRenderer implements RendererInterface
{
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
* @param CriticalCSSCollectorInterface $criticalCollector Colector de CSS crítico
*/
public function __construct(
private CSSGeneratorInterface $cssGenerator
private CSSGeneratorInterface $cssGenerator,
private CriticalCSSCollectorInterface $criticalCollector
) {}
public function render(Component $component): string
@@ -47,6 +53,17 @@ final class HeroRenderer implements RendererInterface
$css = $this->generateCSS($data);
$html = $this->buildHTML($data);
// Verificar si el CSS debe ser crítico (inyectado en <head>)
$isCritical = isset($data['visibility']['is_critical']) &&
$data['visibility']['is_critical'] === true;
if ($isCritical) {
// CSS crítico: agregar al collector para inyección en <head>
$this->criticalCollector->add('hero', $css);
return $html; // Solo HTML, CSS se inyecta en <head>
}
// CSS no crítico: incluir inline con el componente
return sprintf("<style>%s</style>\n%s", $css, $html);
}

View File

@@ -6,6 +6,7 @@ namespace ROITheme\Public\Navbar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Contracts\CriticalCSSCollectorInterface;
use Walker_Nav_Menu;
/**
@@ -30,9 +31,11 @@ final class NavbarRenderer implements RendererInterface
{
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
* @param CriticalCSSCollectorInterface $criticalCollector Colector de CSS crítico
*/
public function __construct(
private CSSGeneratorInterface $cssGenerator
private CSSGeneratorInterface $cssGenerator,
private CriticalCSSCollectorInterface $criticalCollector
) {}
public function render(Component $component): string
@@ -46,6 +49,17 @@ final class NavbarRenderer implements RendererInterface
$css = $this->generateCSS($data);
$html = $this->buildMenu($data);
// Verificar si el CSS debe ser crítico (inyectado en <head>)
$isCritical = isset($data['visibility']['is_critical']) &&
$data['visibility']['is_critical'] === true;
if ($isCritical) {
// CSS crítico: agregar al collector para inyección en <head>
$this->criticalCollector->add('navbar', $css);
return $html; // Solo HTML, CSS se inyecta en <head>
}
// CSS no crítico: incluir inline con el componente
return sprintf(
"<style>%s</style>\n%s",
$css,

View File

@@ -5,6 +5,7 @@ namespace ROITheme\Public\TopNotificationBar\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Contracts\CriticalCSSCollectorInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
@@ -36,9 +37,11 @@ final class TopNotificationBarRenderer implements RendererInterface
{
/**
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
* @param CriticalCSSCollectorInterface $criticalCollector Colector de CSS crítico
*/
public function __construct(
private CSSGeneratorInterface $cssGenerator
private CSSGeneratorInterface $cssGenerator,
private CriticalCSSCollectorInterface $criticalCollector
) {}
/**
@@ -64,7 +67,17 @@ final class TopNotificationBarRenderer implements RendererInterface
// Generar HTML
$html = $this->buildHTML($data);
// Combinar todo
// Verificar si el CSS debe ser crítico (inyectado en <head>)
$isCritical = isset($data['visibility']['is_critical']) &&
$data['visibility']['is_critical'] === true;
if ($isCritical) {
// CSS crítico: agregar al collector para inyección en <head>
$this->criticalCollector->add('top-notification-bar', $css);
return $html; // Solo HTML, CSS se inyecta en <head>
}
// CSS no crítico: incluir inline con el componente
return sprintf(
"<style>%s</style>\n%s",
$css,

View File

@@ -44,6 +44,13 @@
"home": "Solo página de inicio"
},
"description": "Define en qué tipo de contenido se mostrará el hero"
},
"is_critical": {
"type": "boolean",
"label": "CSS Crítico",
"default": true,
"editable": true,
"description": "Inyectar CSS inline en <head> para optimizar LCP (componente above-the-fold)"
}
}
},

View File

@@ -48,6 +48,13 @@
"default": true,
"editable": true,
"description": "Mantiene el navbar fijo en la parte superior al hacer scroll"
},
"is_critical": {
"type": "boolean",
"label": "CSS Crítico",
"default": true,
"editable": true,
"description": "Inyectar CSS inline en <head> para optimizar LCP (componente above-the-fold)"
}
}
},

View File

@@ -44,6 +44,13 @@
"editable": true,
"required": true,
"description": "Muestra la barra en dispositivos móviles (pantallas pequeñas)"
},
"is_critical": {
"type": "boolean",
"label": "CSS Crítico",
"default": true,
"editable": true,
"description": "Inyectar CSS inline en <head> para optimizar LCP (componente above-the-fold)"
}
}
},

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para recolectar CSS crítico de componentes above-the-fold
*
* RESPONSABILIDAD:
* - Definir contrato para recolección de CSS crítico
* - Permitir inyección en Renderers (DIP)
*
* UBICACIÓN: Domain (según 00.02 líneas 239-252)
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface CriticalCSSCollectorInterface
{
/**
* Agregar CSS de un componente a la colección crítica
*
* @param string $componentName Identificador del componente (kebab-case)
* @param string $css CSS generado del componente
*/
public function add(string $componentName, string $css): void;
/**
* Obtener todo el CSS crítico recolectado
*
* @return array<string, string> [componentName => css]
*/
public function getAll(): array;
/**
* Renderizar CSS crítico como tag <style>
*
* @return string HTML del tag <style> o string vacío si no hay CSS
*/
public function render(): string;
/**
* Limpiar colección (útil para tests)
*/
public function clear(): void;
}

View File

@@ -9,6 +9,7 @@ use ROITheme\Shared\Domain\Contracts\ValidationServiceInterface;
use ROITheme\Shared\Domain\Contracts\CacheServiceInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Shared\Domain\Contracts\CriticalCSSCollectorInterface;
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressComponentRepository;
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressDefaultsRepository;
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressComponentSettingsRepository;
@@ -17,6 +18,7 @@ use ROITheme\Shared\Infrastructure\Services\WordPressCacheService;
use ROITheme\Shared\Infrastructure\Services\SchemaSyncService;
use ROITheme\Shared\Infrastructure\Services\CleanupService;
use ROITheme\Shared\Infrastructure\Services\CSSGeneratorService;
use ROITheme\Shared\Infrastructure\Services\CriticalCSSCollector;
use ROITheme\Shared\Application\UseCases\GetComponentSettings\GetComponentSettingsUseCase;
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer;
@@ -253,4 +255,21 @@ final class DIContainer
return $this->instances['adsensePlacementRenderer'];
}
/**
* Obtener colector de CSS crítico
*
* Lazy initialization: Crea la instancia solo en la primera llamada
* IMPORTANTE: Singleton - misma instancia para todos los Renderers
*
* @return CriticalCSSCollectorInterface
*/
public function getCriticalCSSCollector(): CriticalCSSCollectorInterface
{
if (!isset($this->instances['criticalCSSCollector'])) {
$this->instances['criticalCSSCollector'] = new CriticalCSSCollector();
}
return $this->instances['criticalCSSCollector'];
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\CriticalCSSCollectorInterface;
/**
* Implementación del colector de CSS crítico
*
* RESPONSABILIDAD:
* - Recolectar CSS de componentes above-the-fold
* - Renderizar como <style> inline en <head>
*
* PATRÓN:
* - Singleton via DIContainer (NO estático)
* - Cumple DIP: Renderers reciben interface, no clase concreta
*
* UBICACIÓN: Infrastructure/Services (según 00.02)
*
* @package ROITheme\Shared\Infrastructure\Services
*/
final class CriticalCSSCollector implements CriticalCSSCollectorInterface
{
/** @var array<string, string> */
private array $criticalStyles = [];
/**
* {@inheritDoc}
*/
public function add(string $componentName, string $css): void
{
$this->criticalStyles[$componentName] = $css;
}
/**
* {@inheritDoc}
*/
public function getAll(): array
{
return $this->criticalStyles;
}
/**
* {@inheritDoc}
*/
public function render(): string
{
if (empty($this->criticalStyles)) {
return '';
}
$css = implode("\n", $this->criticalStyles);
return sprintf(
'<style id="roi-critical-css">%s</style>',
$css
);
}
/**
* {@inheritDoc}
*/
public function clear(): void
{
$this->criticalStyles = [];
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Wordpress;
use ROITheme\Shared\Domain\Contracts\CriticalCSSCollectorInterface;
/**
* Registra hook wp_head para inyectar CSS crítico
*
* RESPONSABILIDAD:
* - Registrar hook wp_head
* - Delegar renderizado a CriticalCSSCollector
*
* PATRÓN:
* - DIP: Recibe interface, no clase concreta
* - SRP: Solo registra hook, no contiene lógica de CSS
*
* UBICACIÓN: Infrastructure/Wordpress (según 00.02 líneas 307-311)
*
* @package ROITheme\Shared\Infrastructure\Wordpress
*/
final class CriticalCSSHooksRegistrar
{
public function __construct(
private readonly CriticalCSSCollectorInterface $collector
) {}
/**
* Registrar hooks de WordPress
*/
public function register(): void
{
// Priority 1 = muy temprano en <head>, antes de otros estilos
add_action('wp_head', [$this, 'renderCriticalCSS'], 1);
}
/**
* Callback para wp_head
*/
public function renderCriticalCSS(): void
{
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->collector->render();
}
}

View File

@@ -114,6 +114,28 @@ function roi_get_navbar_setting(string $group, string $attribute, $default = nul
return $value;
}
// =============================================================================
// CRITICAL CSS COLLECTOR SINGLETON
// =============================================================================
/**
* Obtiene la instancia singleton del CriticalCSSCollector
*
* Patrón Singleton implementado via función para mantener una única instancia
* que será compartida por todos los Renderers y el HooksRegistrar
*
* @return \ROITheme\Shared\Domain\Contracts\CriticalCSSCollectorInterface
*/
function roi_get_critical_css_collector(): \ROITheme\Shared\Domain\Contracts\CriticalCSSCollectorInterface {
static $collector = null;
if ($collector === null) {
$collector = new \ROITheme\Shared\Infrastructure\Services\CriticalCSSCollector();
}
return $collector;
}
// =============================================================================
// HELPER FUNCTION: roi_render_component()
// =============================================================================
@@ -186,22 +208,27 @@ function roi_render_component(string $componentName): string {
// Crear instancia del CSSGeneratorService (reutilizable para todos los renderers que lo necesiten)
$cssGenerator = new \ROITheme\Shared\Infrastructure\Services\CSSGeneratorService();
// Obtener instancia singleton del CriticalCSSCollector
$criticalCollector = roi_get_critical_css_collector();
switch ($componentName) {
// Componentes nuevos (namespace PascalCase correcto)
// Componentes con soporte de CSS Crítico (above-the-fold)
case 'top-notification-bar':
$renderer = new \ROITheme\Public\TopNotificationBar\Infrastructure\Ui\TopNotificationBarRenderer($cssGenerator);
$renderer = new \ROITheme\Public\TopNotificationBar\Infrastructure\Ui\TopNotificationBarRenderer($cssGenerator, $criticalCollector);
break;
case 'navbar':
$renderer = new \ROITheme\Public\Navbar\Infrastructure\Ui\NavbarRenderer($cssGenerator);
break;
case 'cta-lets-talk':
$renderer = new \ROITheme\Public\CtaLetsTalk\Infrastructure\Ui\CtaLetsTalkRenderer($cssGenerator);
$renderer = new \ROITheme\Public\Navbar\Infrastructure\Ui\NavbarRenderer($cssGenerator, $criticalCollector);
break;
case 'hero':
error_log("ROI Theme DEBUG: Creating HeroRenderer");
$renderer = new \ROITheme\Public\Hero\Infrastructure\Ui\HeroRenderer($cssGenerator);
$renderer = new \ROITheme\Public\Hero\Infrastructure\Ui\HeroRenderer($cssGenerator, $criticalCollector);
error_log("ROI Theme DEBUG: HeroRenderer created successfully");
break;
// Componentes sin soporte de CSS Crítico (below-the-fold)
case 'cta-lets-talk':
$renderer = new \ROITheme\Public\CtaLetsTalk\Infrastructure\Ui\CtaLetsTalkRenderer($cssGenerator);
break;
case 'hero-section':
$renderer = new \ROITheme\Public\HeroSection\Infrastructure\Ui\HeroSectionRenderer();
break;
@@ -250,9 +277,21 @@ function roi_render_component(string $componentName): string {
}
// =============================================================================
// ESTILOS BASE PARA TOP NOTIFICATION BAR
// REGISTRO DE CRITICAL CSS HOOKS
// =============================================================================
/**
* Registra el hook para inyectar CSS crítico en <head>
*
* IMPORTANTE: El HooksRegistrar usa la misma instancia singleton del collector
* que usan los Renderers, garantizando que el CSS recolectado se inyecte
* correctamente en wp_head con prioridad 1 (muy temprano).
*/
add_action('after_setup_theme', function() {
$criticalCollector = roi_get_critical_css_collector();
$hooksRegistrar = new \ROITheme\Shared\Infrastructure\Wordpress\CriticalCSSHooksRegistrar($criticalCollector);
$hooksRegistrar->register();
});
// =============================================================================
// NOTA: Los estilos de TOC y CTA Box Sidebar se generan dinámicamente