feat(critical-css): implementar TIPO 4 y TIPO 5 - CSS Below-the-fold y Lazy Loading

## TIPO 4: CSS Below-the-fold (Critical Variables + Responsive)
- Inyecta variables CSS críticas inline en wp_head P:-1
- Inyecta media queries críticas inline en wp_head P:2 (corregido de P:1)
- Auto-regeneración cuando archivos fuente cambian (filemtime check)
- Cache en Assets/CriticalCSS/ para evitar lecturas repetidas
- Comando WP-CLI: wp roi-theme generate-critical-css

Archivos TIPO 4:
- Public/CriticalCSS/Domain/Contracts/ - Interfaces (DIP)
- Public/CriticalCSS/Application/UseCases/GetCriticalCSSUseCase.php
- Public/CriticalCSS/Infrastructure/Cache/CriticalCSSFileCache.php
- Public/CriticalCSS/Infrastructure/Services/CriticalCSSExtractor.php
- Public/CriticalCSS/Infrastructure/Services/CriticalCSSInjector.php
- bin/generate-critical-css.php

## TIPO 5: CSS No Crítico (Lazy Loading)
- Animaciones CSS: carga 2s después de page load via requestIdleCallback
- Print CSS: carga solo al imprimir via beforeprint event
- Fallback <noscript> para usuarios sin JavaScript
- Safari fallback: setTimeout cuando requestIdleCallback no disponible

Archivos TIPO 5:
- Assets/Js/lazy-css-loader.js
- Public/LazyCSSLoader/Infrastructure/Contracts/LazyCSSRegistrarInterface.php
- Public/LazyCSSLoader/Infrastructure/Services/LazyCSSRegistrar.php

## Fix: Colisión de prioridades wp_head
Antes: TIPO 1 (P:1), TIPO 4 responsive (P:1), TIPO 3 (P:2) - CONFLICTO
Después: TIPO 1 (P:1), TIPO 4 responsive (P:2), TIPO 3 (P:3) - OK

Nuevo orden de prioridades:
P:-1 roi-critical-variables (TIPO 4)
P:0  roi-critical-bootstrap (TIPO 2)
P:1  roi-critical-css (TIPO 1)
P:2  roi-critical-responsive (TIPO 4)
P:3  roi-custom-critical-css (TIPO 3)
P:5  roi-theme-layout-css (ThemeSettings)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-01 23:06:12 -06:00
parent e1923b630d
commit e01605ec37
15 changed files with 961 additions and 17 deletions

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CriticalCSS\Application\UseCases;
use ROITheme\Public\CriticalCSS\Domain\Contracts\CriticalCSSCacheInterface;
use ROITheme\Public\CriticalCSS\Domain\Contracts\CriticalCSSExtractorInterface;
/**
* Caso de uso para obtener/generar CSS critico
*
* Cumple DIP: depende de interfaces, no de clases concretas
*
* @package ROITheme\Public\CriticalCSS\Application\UseCases
*/
final class GetCriticalCSSUseCase
{
public function __construct(
private readonly CriticalCSSCacheInterface $cache,
private readonly CriticalCSSExtractorInterface $extractor
) {}
/**
* Obtiene CSS critico, regenerando si es necesario
*
* @param string $type Tipo de CSS critico ('variables', 'responsive')
* @return string CSS critico o vacio si no existe
*/
public function execute(string $type): string
{
// Si no existe en cache, generar
if (!$this->cache->has($type)) {
$this->extractor->generateAll();
}
return $this->cache->get($type) ?? '';
}
/**
* Fuerza regeneracion de todo el CSS critico
*
* @return array<string, int> Mapa de tipo => bytes generados
*/
public function regenerate(): array
{
$this->cache->clear();
return $this->extractor->generateAll();
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CriticalCSS\Domain\Contracts;
/**
* Interface para cache de CSS critico
*
* Define el contrato para almacenamiento y recuperacion
* de CSS critico generado (TIPO 4).
*
* @package ROITheme\Public\CriticalCSS\Domain\Contracts
*/
interface CriticalCSSCacheInterface
{
/**
* Obtiene CSS del cache
*
* @param string $key Clave del cache (ej: 'variables', 'responsive')
* @return string|null CSS cacheado o null si no existe
*/
public function get(string $key): ?string;
/**
* Guarda CSS en cache
*
* @param string $key Clave del cache
* @param string $css Contenido CSS a guardar
* @return bool True si se guardo correctamente
*/
public function set(string $key, string $css): bool;
/**
* Verifica si existe en cache
*
* @param string $key Clave del cache
* @return bool True si existe
*/
public function has(string $key): bool;
/**
* Limpia todo el cache
*
* @return void
*/
public function clear(): void;
/**
* Verifica si el cache esta desactualizado respecto al archivo fuente
*
* Compara filemtime del archivo fuente vs archivo cache.
* Si el fuente es mas nuevo, el cache esta desactualizado.
*
* @param string $key Clave del cache
* @param string $sourceFile Ruta absoluta al archivo fuente
* @return bool True si el cache es mas viejo que el fuente
*/
public function isStale(string $key, string $sourceFile): bool;
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CriticalCSS\Domain\Contracts;
/**
* Interface para extraccion de CSS critico
*
* Define el contrato para extraer CSS critico de archivos fuente
* y generar versiones optimizadas para inline (TIPO 4).
*
* @package ROITheme\Public\CriticalCSS\Domain\Contracts
*/
interface CriticalCSSExtractorInterface
{
/**
* Extrae CSS de variables
*
* El archivo css-global-variables.css completo es critico
* porque define las variables CSS usadas en todo el sitio.
*
* @return string CSS de variables minificado
*/
public function extractVariables(): string;
/**
* Extrae media queries criticas
*
* Solo extrae breakpoints mobile-first criticos de
* css-global-responsive.css para evitar layout shift.
*
* @return string CSS responsive critico minificado
*/
public function extractResponsive(): string;
/**
* Genera y cachea todo el CSS critico
*
* Extrae variables y responsive, los minifica y guarda en cache.
*
* @return array<string, int> Mapa de tipo => bytes generados
*/
public function generateAll(): array;
/**
* Verifica si algun archivo fuente ha cambiado
* respecto al cache generado
*
* Usado para auto-regeneracion en el bootstrap.
*
* @return bool True si se necesita regenerar
*/
public function needsRegeneration(): bool;
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CriticalCSS\Infrastructure\Cache;
use ROITheme\Public\CriticalCSS\Domain\Contracts\CriticalCSSCacheInterface;
/**
* Cache de CSS critico en filesystem con auto-invalidacion
*
* Almacena CSS critico generado en archivos .critical.css
* dentro de Assets/CriticalCSS/
*
* @package ROITheme\Public\CriticalCSS\Infrastructure\Cache
*/
final class CriticalCSSFileCache implements CriticalCSSCacheInterface
{
private readonly string $cacheDir;
public function __construct()
{
$this->cacheDir = get_template_directory() . '/Assets/CriticalCSS';
}
/**
* {@inheritDoc}
*/
public function get(string $key): ?string
{
$file = $this->getFilePath($key);
if (!file_exists($file)) {
return null;
}
$content = file_get_contents($file);
return $content !== false ? $content : null;
}
/**
* {@inheritDoc}
*/
public function set(string $key, string $css): bool
{
$this->ensureDirectoryExists();
$file = $this->getFilePath($key);
return file_put_contents($file, $css) !== false;
}
/**
* {@inheritDoc}
*/
public function has(string $key): bool
{
return file_exists($this->getFilePath($key));
}
/**
* {@inheritDoc}
*/
public function clear(): void
{
$files = glob($this->cacheDir . '/*.critical.css');
if ($files === false) {
return;
}
foreach ($files as $file) {
unlink($file);
}
}
/**
* {@inheritDoc}
*
* Compara timestamps para detectar cache desactualizado.
* Costo: ~0.1ms (2 llamadas a filemtime)
*/
public function isStale(string $key, string $sourceFile): bool
{
$cacheFile = $this->getFilePath($key);
// Si no existe cache, esta desactualizado
if (!file_exists($cacheFile)) {
return true;
}
// Si no existe fuente, no esta desactualizado (nada que regenerar)
if (!file_exists($sourceFile)) {
return false;
}
// Comparar timestamps
return filemtime($sourceFile) > filemtime($cacheFile);
}
/**
* Genera ruta del archivo cache
*/
private function getFilePath(string $key): string
{
return $this->cacheDir . '/' . $key . '.critical.css';
}
/**
* Asegura que el directorio de cache existe
*/
private function ensureDirectoryExists(): void
{
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CriticalCSS\Infrastructure\Services;
use ROITheme\Public\CriticalCSS\Domain\Contracts\CriticalCSSCacheInterface;
use ROITheme\Public\CriticalCSS\Domain\Contracts\CriticalCSSExtractorInterface;
/**
* Extrae CSS critico de archivos fuente
*
* Estrategia simple:
* - Variables: Todo el archivo (siempre critico)
* - Responsive: Solo breakpoints mobile-first criticos
*
* @package ROITheme\Public\CriticalCSS\Infrastructure\Services
*/
final class CriticalCSSExtractor implements CriticalCSSExtractorInterface
{
// Archivos fuente
private const SOURCE_VARIABLES = '/Assets/Css/css-global-variables.css';
private const SOURCE_RESPONSIVE = '/Assets/Css/css-global-responsive.css';
// Breakpoints criticos (mobile-first)
// NOTA: El regex maneja 1 nivel de anidamiento.
// Si el CSS tiene @supports dentro de @media, no se capturara correctamente.
// Esto es aceptable para css-global-responsive.css que es simple.
private const CRITICAL_BREAKPOINTS = [
'@media (max-width: 575.98px)',
'@media (min-width: 576px)',
'@media (max-width: 767.98px)',
'@media (min-width: 768px)',
'@media (min-width: 992px)',
];
public function __construct(
private readonly CriticalCSSCacheInterface $cache
) {}
/**
* {@inheritDoc}
*/
public function extractVariables(): string
{
$sourceFile = get_template_directory() . self::SOURCE_VARIABLES;
if (!file_exists($sourceFile)) {
return '';
}
$css = file_get_contents($sourceFile);
if ($css === false) {
return '';
}
// Para variables, todo el contenido es critico
return $this->minify($css);
}
/**
* {@inheritDoc}
*/
public function extractResponsive(): string
{
$sourceFile = get_template_directory() . self::SOURCE_RESPONSIVE;
if (!file_exists($sourceFile)) {
return '';
}
$css = file_get_contents($sourceFile);
if ($css === false) {
return '';
}
$criticalCSS = '';
// Extraer solo media queries criticas
// Regex: captura @media (...) { contenido con 1 nivel de {} }
foreach (self::CRITICAL_BREAKPOINTS as $breakpoint) {
$pattern = '/' . preg_quote($breakpoint, '/') . '\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/s';
if (preg_match_all($pattern, $css, $matches)) {
foreach ($matches[0] as $match) {
$criticalCSS .= $match . "\n";
}
}
}
return $this->minify($criticalCSS);
}
/**
* {@inheritDoc}
*/
public function generateAll(): array
{
$results = [];
// Variables CSS
$variablesCSS = $this->extractVariables();
if (!empty($variablesCSS)) {
$this->cache->set('variables', $variablesCSS);
$results['variables'] = strlen($variablesCSS);
}
// Responsive critico
$responsiveCSS = $this->extractResponsive();
if (!empty($responsiveCSS)) {
$this->cache->set('responsive', $responsiveCSS);
$results['responsive'] = strlen($responsiveCSS);
}
return $results;
}
/**
* {@inheritDoc}
*/
public function needsRegeneration(): bool
{
$templateDir = get_template_directory();
return $this->cache->isStale('variables', $templateDir . self::SOURCE_VARIABLES)
|| $this->cache->isStale('responsive', $templateDir . self::SOURCE_RESPONSIVE);
}
/**
* Minifica CSS eliminando espacios y comentarios innecesarios
*/
private function minify(string $css): string
{
// Eliminar comentarios
$css = preg_replace('/\/\*[\s\S]*?\*\//', '', $css) ?? $css;
// Eliminar espacios innecesarios
$css = preg_replace('/\s+/', ' ', $css) ?? $css;
$css = preg_replace('/\s*([{};:,>+~])\s*/', '$1', $css) ?? $css;
// Eliminar ultimo punto y coma antes de }
$css = preg_replace('/;}/', '}', $css) ?? $css;
return trim($css);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\CriticalCSS\Infrastructure\Services;
use ROITheme\Public\CriticalCSS\Domain\Contracts\CriticalCSSCacheInterface;
/**
* Inyecta CSS critico en wp_head
*
* Prioridades:
* - P:-1 Variables CSS (antes de todo)
* - P:1 Responsive critico (despues de Bootstrap critico)
*
* @package ROITheme\Public\CriticalCSS\Infrastructure\Services
*/
final class CriticalCSSInjector
{
public function __construct(
private readonly CriticalCSSCacheInterface $cache
) {}
/**
* Registra hooks de WordPress
*/
public function register(): void
{
// Variables CSS: P:-1 (antes de CriticalBootstrapService P:0)
add_action('wp_head', [$this, 'injectVariables'], -1);
// Responsive critico: P:2 (despues de CriticalCSSService P:1)
// NOTA: Cambiado de P:1 a P:2 para evitar colision con roi-critical-css
add_action('wp_head', [$this, 'injectResponsive'], 2);
// Deshabilitar enqueue de archivos que ahora son inline
add_action('wp_enqueue_scripts', [$this, 'dequeueInlinedCSS'], 999);
}
/**
* Inyecta variables CSS criticas
*/
public function injectVariables(): void
{
$css = $this->cache->get('variables');
if (empty($css)) {
return;
}
printf(
'<!-- TIPO 4: Variables CSS criticas -->' . "\n" .
'<style id="roi-critical-variables">%s</style>' . "\n",
$css
);
}
/**
* Inyecta media queries criticas
*/
public function injectResponsive(): void
{
$css = $this->cache->get('responsive');
if (empty($css)) {
return;
}
printf(
'<!-- TIPO 4: Responsive critico -->' . "\n" .
'<style id="roi-critical-responsive">%s</style>' . "\n",
$css
);
}
/**
* Deshabilita enqueue de archivos que ahora estan inline
*/
public function dequeueInlinedCSS(): void
{
// Variables ya inline - no cargar archivo externo
if ($this->cache->has('variables')) {
wp_dequeue_style('roi-variables');
wp_deregister_style('roi-variables');
}
}
}

View File

@@ -23,8 +23,9 @@ final class CustomCSSInjector
*/
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 crítico: priority 3 (después de TIPO 4 responsive P:2, antes de theme-settings P:5)
// NOTA: Cambiado de P:2 a P:3 para evitar colision con roi-critical-responsive
add_action('wp_head', [$this, 'injectCriticalCSS'], 3);
// CSS diferido: priority alta en footer
add_action('wp_footer', [$this, 'injectDeferredCSS'], 10);

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\LazyCSSLoader\Infrastructure\Contracts;
/**
* Interface para registrar CSS lazy (TIPO 5)
*
* Cumple DIP: permite inyeccion de dependencias y testing
*
* @package ROITheme\Public\LazyCSSLoader\Infrastructure\Contracts
*/
interface LazyCSSRegistrarInterface
{
/**
* Registra hooks de WordPress para lazy loading
*/
public function register(): void;
/**
* Remueve CSS que ahora es TIPO 5 del enqueue normal
*/
public function dequeueType5CSS(): void;
/**
* Encola el loader JavaScript con configuracion
*/
public function enqueueLoader(): void;
/**
* Agrega fallback noscript para accesibilidad
*/
public function addNoscriptFallback(): void;
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\LazyCSSLoader\Infrastructure\Services;
use ROITheme\Public\LazyCSSLoader\Infrastructure\Contracts\LazyCSSRegistrarInterface;
/**
* Registra CSS para carga lazy (TIPO 5)
*
* Separa CSS de TIPO 2 (diferido inmediato) vs TIPO 5 (lazy loading)
*
* CSS incluidos en TIPO 5:
* - Animaciones: requestIdleCallback (2s timeout)
* - Print: beforeprint event
*
* @package ROITheme\Public\LazyCSSLoader\Infrastructure\Services
*/
final class LazyCSSRegistrar implements LazyCSSRegistrarInterface
{
/**
* CSS que se mueve de TIPO 2 a TIPO 5
*
* @var array<int, array{id: string, path: string, trigger: string, handle: string}>
*/
private const LAZY_CSS_FILES = [
[
'id' => 'animations',
'path' => '/Assets/Css/css-global-animations.css',
'trigger' => 'idle',
'handle' => 'roi-animations',
],
[
'id' => 'print',
'path' => '/Assets/Css/css-global-print.css',
'trigger' => 'print',
'handle' => 'roi-print',
],
];
/**
* Timeout en ms para requestIdleCallback
*/
private const IDLE_TIMEOUT = 2000;
/**
* Registra hooks de WordPress
*/
public function register(): void
{
// Dequeue CSS que ahora es TIPO 5
add_action('wp_enqueue_scripts', [$this, 'dequeueType5CSS'], 999);
// Agregar script loader y configuracion
add_action('wp_enqueue_scripts', [$this, 'enqueueLoader'], 20);
// Agregar fallback noscript
add_action('wp_head', [$this, 'addNoscriptFallback'], 99);
}
/**
* Remueve CSS que ahora es TIPO 5 del enqueue normal
*/
public function dequeueType5CSS(): void
{
foreach (self::LAZY_CSS_FILES as $file) {
wp_dequeue_style($file['handle']);
wp_deregister_style($file['handle']);
}
}
/**
* Encola el loader JavaScript con configuracion
*/
public function enqueueLoader(): void
{
$loaderPath = get_template_directory() . '/Assets/Js/lazy-css-loader.js';
// Solo encolar si el archivo existe
if (!file_exists($loaderPath)) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('ROI Theme TIPO 5: lazy-css-loader.js not found');
}
return;
}
wp_enqueue_script(
'roi-lazy-css-loader',
get_template_directory_uri() . '/Assets/Js/lazy-css-loader.js',
[],
ROI_VERSION,
true // En footer
);
// Pasar configuracion al JS
wp_localize_script('roi-lazy-css-loader', 'roiLazyCSSConfig', [
'baseUrl' => get_template_directory_uri(),
'version' => ROI_VERSION,
'idleTimeout' => self::IDLE_TIMEOUT,
'cssFiles' => self::LAZY_CSS_FILES,
]);
}
/**
* Agrega fallback noscript para accesibilidad
*/
public function addNoscriptFallback(): void
{
echo '<noscript>' . "\n";
foreach (self::LAZY_CSS_FILES as $file) {
$href = esc_url(get_template_directory_uri() . $file['path'] . '?ver=' . ROI_VERSION);
$media = $file['trigger'] === 'print' ? 'print' : 'all';
printf(
'<link rel="stylesheet" href="%s" media="%s">' . "\n",
$href,
$media
);
}
echo '</noscript>' . "\n";
}
}