9 Commits

Author SHA1 Message Date
FrankZamora
85f3387fd2 perf(php): add conditional debug logging to prevent gb logs
- add ROI_DEBUG constant (default false) to control debug output
- create roi_debug_log() function for conditional logging
- replace all error_log DEBUG calls with roi_debug_log
- keep ERROR logs always active for exception tracking
- to enable debug, add define('ROI_DEBUG', true) in wp-config.php

this prevents production logs from growing to gb sizes
(previous error.log was 4.8gb from constant debug output)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:52:50 -06:00
FrankZamora
ff5ba25505 feat(php): implement cache-first architecture hook
Add CacheFirstHooksRegistrar that fires roi_theme_before_page_serve
hook on template_redirect priority 0 for singular pages.

- Only fires for anonymous users (cache doesn't apply to logged in)
- Only fires for singular pages (posts, pages, CPTs)
- Provides post_id to external plugins
- Does NOT define DONOTCACHEPAGE (allows page caching)

Plan 1000.01 implementation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 12:11:48 -06:00
FrankZamora
eab974d14c docs(config): add cache-first-architecture specification
Define arquitectura cache-first para ROI-Theme:
- Hook roi_theme_before_page_serve en template_redirect p=0
- Solo para páginas singulares y usuarios anónimos
- Permite que plugins externos evalúen acceso antes de servir página
- NO define DONOTCACHEPAGE (permite cache)

Plan 1000.01 - Preparación para integración con IP View Limit.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 12:07:14 -06:00
FrankZamora
b509b1a2b4 fix(php): toc fallback to raw content when filtered has no headings
When plugins like Thrive Visual Editor transform content for
non-logged users, headings may be removed from the filtered content.
This fix uses raw post_content as fallback when filtered content
has no headings but raw content does.

Also removes temporary debug logging added for diagnosis.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:08:24 -06:00
FrankZamora
83d113d669 chore(php): add more debug for toc heading detection
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:05:51 -06:00
FrankZamora
0c1908e7d1 chore(php): add toc debug logging for guest visibility issue
Temporary debug logging to diagnose why TOC shows for logged users
but not for guests. Logs visibility checks at each layer.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:03:42 -06:00
FrankZamora
5333531be4 fix(templates): add missing components to archive templates
- Add social-share component to category.php and archive.php
- Add related-post component to category.php and archive.php
- Now archive templates have same components as single.php

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 22:40:53 -06:00
FrankZamora
fb68f2023c fix(theme): improve post-grid spacing, pagination and archive templates
- Fix flexbox gap issue causing unequal horizontal/vertical spacing
- Reset Bootstrap row/col margins to use only CSS gap property
- Replace WordPress pagination with Bootstrap-style pagination
- Add cta-post component to category.php and archive.php templates
- Fix spacing controls UI with separate horizontal/vertical gap fields
- Update FieldMapper with new gap_horizontal and gap_vertical attributes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 22:26:19 -06:00
FrankZamora
79e91f59ee feat(theme): add [roi_post_grid] shortcode for static pages
- Create PostGridShortcodeRegistrar for WordPress shortcode registration
- Implement RenderPostGridUseCase following Clean Architecture
- Add PostGridQueryBuilder for custom WP_Query construction
- Add PostGridShortcodeRenderer for HTML/CSS generation
- Register shortcode in DIContainer with proper DI
- Add shortcode usage guide in post-grid admin panel
- Fix sidebar layout: add hide_for_logged_in check to wrapper visibility

Shortcode attributes: category, tag, author, posts_per_page, columns,
show_pagination, show_thumbnail, show_excerpt, show_meta, etc.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 21:33:20 -06:00
21 changed files with 2040 additions and 56 deletions

View File

@@ -81,7 +81,8 @@ final class PostGridFieldMapper implements FieldMapperInterface
'postGridPaginationActiveColor' => ['group' => 'colors', 'attribute' => 'pagination_active_color'],
// Spacing
'postGridGridGap' => ['group' => 'spacing', 'attribute' => 'grid_gap'],
'postGridGapHorizontal' => ['group' => 'spacing', 'attribute' => 'gap_horizontal'],
'postGridGapVertical' => ['group' => 'spacing', 'attribute' => 'gap_vertical'],
'postGridCardPadding' => ['group' => 'spacing', 'attribute' => 'card_padding'],
'postGridSectionMarginTop' => ['group' => 'spacing', 'attribute' => 'section_margin_top'],
'postGridSectionMarginBottom' => ['group' => 'spacing', 'attribute' => 'section_margin_bottom'],

View File

@@ -33,14 +33,15 @@ final class PostGridFormBuilder
// Columna izquierda
$html .= '<div class="col-lg-6">';
$html .= $this->buildShortcodeGuide();
$html .= $this->buildVisibilityGroup($componentId);
$html .= $this->buildContentGroup($componentId);
$html .= $this->buildLayoutGroup($componentId);
$html .= $this->buildMediaGroup($componentId);
$html .= '</div>';
// Columna derecha
$html .= '<div class="col-lg-6">';
$html .= $this->buildLayoutGroup($componentId);
$html .= $this->buildTypographyGroup($componentId);
$html .= $this->buildColorsGroup($componentId);
$html .= $this->buildSpacingGroup($componentId);
@@ -206,11 +207,11 @@ final class PostGridFormBuilder
// Columns desktop
$colsDesktop = $this->renderer->getFieldValue($componentId, 'layout', 'columns_desktop', '3');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridColsDesktop" class="form-label small mb-1 fw-semibold">';
$html .= ' <label for="postGridColumnsDesktop" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-display me-1" style="color: #FF8600;"></i>';
$html .= ' Columnas escritorio';
$html .= ' </label>';
$html .= ' <select id="postGridColsDesktop" class="form-select form-select-sm">';
$html .= ' <select id="postGridColumnsDesktop" class="form-select form-select-sm">';
$html .= ' <option value="2"' . ($colsDesktop === '2' ? ' selected' : '') . '>2 columnas</option>';
$html .= ' <option value="3"' . ($colsDesktop === '3' ? ' selected' : '') . '>3 columnas</option>';
$html .= ' <option value="4"' . ($colsDesktop === '4' ? ' selected' : '') . '>4 columnas</option>';
@@ -220,11 +221,11 @@ final class PostGridFormBuilder
// Columns tablet
$colsTablet = $this->renderer->getFieldValue($componentId, 'layout', 'columns_tablet', '2');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridColsTablet" class="form-label small mb-1 fw-semibold">';
$html .= ' <label for="postGridColumnsTablet" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-tablet me-1" style="color: #FF8600;"></i>';
$html .= ' Columnas tablet';
$html .= ' </label>';
$html .= ' <select id="postGridColsTablet" class="form-select form-select-sm">';
$html .= ' <select id="postGridColumnsTablet" class="form-select form-select-sm">';
$html .= ' <option value="1"' . ($colsTablet === '1' ? ' selected' : '') . '>1 columna</option>';
$html .= ' <option value="2"' . ($colsTablet === '2' ? ' selected' : '') . '>2 columnas</option>';
$html .= ' <option value="3"' . ($colsTablet === '3' ? ' selected' : '') . '>3 columnas</option>';
@@ -234,11 +235,11 @@ final class PostGridFormBuilder
// Columns mobile
$colsMobile = $this->renderer->getFieldValue($componentId, 'layout', 'columns_mobile', '1');
$html .= ' <div class="mb-3">';
$html .= ' <label for="postGridColsMobile" class="form-label small mb-1 fw-semibold">';
$html .= ' <label for="postGridColumnsMobile" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-phone me-1" style="color: #FF8600;"></i>';
$html .= ' Columnas movil';
$html .= ' </label>';
$html .= ' <select id="postGridColsMobile" class="form-select form-select-sm">';
$html .= ' <select id="postGridColumnsMobile" class="form-select form-select-sm">';
$html .= ' <option value="1"' . ($colsMobile === '1' ? ' selected' : '') . '>1 columna</option>';
$html .= ' <option value="2"' . ($colsMobile === '2' ? ' selected' : '') . '>2 columnas</option>';
$html .= ' </select>';
@@ -451,38 +452,91 @@ final class PostGridFormBuilder
$html .= ' Espaciado';
$html .= ' </h5>';
// Separación entre cards
$html .= ' <p class="small text-muted mb-2">Separacion entre cards:</p>';
$html .= ' <div class="row g-2 mb-3">';
$gridGap = $this->renderer->getFieldValue($componentId, 'spacing', 'grid_gap', '1.5rem');
// Gap horizontal (entre columnas)
$gapHorizontal = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_horizontal', '24px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridGridGap" class="form-label small mb-1 fw-semibold">Espacio entre cards</label>';
$html .= ' <input type="text" id="postGridGridGap" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($gridGap) . '">';
$html .= ' <label for="postGridGapHorizontal" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrows-expand me-1" style="color: #FF8600;"></i>Horizontal';
$html .= ' </label>';
$html .= ' <select id="postGridGapHorizontal" class="form-select form-select-sm">';
$gapOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px', '40px', '48px'];
foreach ($gapOptions as $opt) {
$selected = ($gapHorizontal === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$cardPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'card_padding', '1.25rem');
// Gap vertical (entre filas)
$gapVertical = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_vertical', '24px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardPadding" class="form-label small mb-1 fw-semibold">Padding card</label>';
$html .= ' <input type="text" id="postGridCardPadding" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($cardPadding) . '">';
$html .= ' <label for="postGridGapVertical" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrows-collapse me-1" style="color: #FF8600;"></i>Vertical';
$html .= ' </label>';
$html .= ' <select id="postGridGapVertical" class="form-select form-select-sm">';
foreach ($gapOptions as $opt) {
$selected = ($gapVertical === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
// Padding interno de cada card
$html .= ' <p class="small text-muted mb-2">Padding interno de card:</p>';
$html .= ' <div class="row g-2 mb-3">';
$cardPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'card_padding', '20px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridCardPadding" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-box me-1" style="color: #FF8600;"></i>Padding';
$html .= ' </label>';
$html .= ' <select id="postGridCardPadding" class="form-select form-select-sm">';
$paddingOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px'];
foreach ($paddingOptions as $opt) {
$selected = ($cardPadding === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$html .= ' <div class="col-6"></div>';
$html .= ' </div>';
// Margenes de la seccion
$html .= ' <p class="small text-muted mb-2">Margenes de la seccion:</p>';
$html .= ' <div class="row g-2 mb-0">';
$sectionMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_top', '0');
$sectionMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_top', '0px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridSectionMarginTop" class="form-label small mb-1 fw-semibold">Margen superior</label>';
$html .= ' <input type="text" id="postGridSectionMarginTop" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionMarginTop) . '">';
$html .= ' <label for="postGridSectionMarginTop" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrow-up me-1" style="color: #FF8600;"></i>Arriba';
$html .= ' </label>';
$html .= ' <select id="postGridSectionMarginTop" class="form-select form-select-sm">';
$marginOptions = ['0px', '8px', '16px', '24px', '32px', '48px', '64px'];
foreach ($marginOptions as $opt) {
$selected = ($sectionMarginTop === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$sectionMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_bottom', '2rem');
$sectionMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_bottom', '32px');
$html .= ' <div class="col-6">';
$html .= ' <label for="postGridSectionMarginBottom" class="form-label small mb-1 fw-semibold">Margen inferior</label>';
$html .= ' <input type="text" id="postGridSectionMarginBottom" class="form-control form-control-sm" ';
$html .= ' value="' . esc_attr($sectionMarginBottom) . '">';
$html .= ' <label for="postGridSectionMarginBottom" class="form-label small mb-1 fw-semibold">';
$html .= ' <i class="bi bi-arrow-down me-1" style="color: #FF8600;"></i>Abajo';
$html .= ' </label>';
$html .= ' <select id="postGridSectionMarginBottom" class="form-select form-select-sm">';
foreach ($marginOptions as $opt) {
$selected = ($sectionMarginBottom === $opt) ? ' selected' : '';
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
}
$html .= ' </select>';
$html .= ' </div>';
$html .= ' </div>';
@@ -616,4 +670,112 @@ final class PostGridFormBuilder
return $html;
}
private function buildShortcodeGuide(): string
{
$html = '<div class="card shadow-sm mb-3" 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-code-square me-2" style="color: #FF8600;"></i>';
$html .= ' Shortcode [roi_post_grid]';
$html .= ' </h5>';
$html .= ' <p class="small text-muted mb-3">';
$html .= ' Usa este shortcode para insertar grids de posts en cualquier pagina o entrada. ';
$html .= ' Los estilos se heredan de la configuracion de este componente.';
$html .= ' </p>';
// Uso basico
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-1-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Uso basico (9 posts, 3 columnas)';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid]</code>';
$html .= ' </div>';
// Por categoria
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-2-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Filtrar por categoria';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid category="precios-unitarios"]</code>';
$html .= ' </div>';
// Personalizar cantidad y columnas
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-3-circle me-1" style="color: #FF8600;"></i>';
$html .= ' 6 posts en 2 columnas';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="6" columns="2"]</code>';
$html .= ' </div>';
// Con paginacion
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-4-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Con paginacion';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="12" show_pagination="true"]</code>';
$html .= ' </div>';
// Filtrar por tag
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-5-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Filtrar por etiqueta';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
$html .= ' <code class="text-warning">[roi_post_grid tag="tutorial"]</code>';
$html .= ' </div>';
// Ejemplo completo
$html .= ' <p class="small fw-semibold mb-1">';
$html .= ' <i class="bi bi-6-circle me-1" style="color: #FF8600;"></i>';
$html .= ' Ejemplo completo';
$html .= ' </p>';
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.75rem;">';
$html .= ' <code class="text-warning">[roi_post_grid category="cursos" posts_per_page="6" columns="3" show_meta="false" show_categories="true"]</code>';
$html .= ' </div>';
// Tabla de atributos
$html .= ' <hr class="my-3">';
$html .= ' <p class="small fw-semibold mb-2">';
$html .= ' <i class="bi bi-list-check me-1" style="color: #FF8600;"></i>';
$html .= ' Atributos disponibles';
$html .= ' </p>';
$html .= ' <div class="table-responsive">';
$html .= ' <table class="table table-sm table-bordered small mb-0">';
$html .= ' <thead class="table-light">';
$html .= ' <tr><th>Atributo</th><th>Default</th><th>Descripcion</th></tr>';
$html .= ' </thead>';
$html .= ' <tbody>';
$html .= ' <tr><td><code>posts_per_page</code></td><td>9</td><td>Cantidad de posts</td></tr>';
$html .= ' <tr><td><code>columns</code></td><td>3</td><td>Columnas (1-4)</td></tr>';
$html .= ' <tr><td><code>category</code></td><td>-</td><td>Slug de categoria</td></tr>';
$html .= ' <tr><td><code>exclude_category</code></td><td>-</td><td>Excluir categoria</td></tr>';
$html .= ' <tr><td><code>tag</code></td><td>-</td><td>Slug de etiqueta</td></tr>';
$html .= ' <tr><td><code>author</code></td><td>-</td><td>ID o username</td></tr>';
$html .= ' <tr><td><code>orderby</code></td><td>date</td><td>date, title, rand</td></tr>';
$html .= ' <tr><td><code>order</code></td><td>DESC</td><td>DESC o ASC</td></tr>';
$html .= ' <tr><td><code>show_pagination</code></td><td>false</td><td>Mostrar paginacion</td></tr>';
$html .= ' <tr><td><code>show_thumbnail</code></td><td>true</td><td>Mostrar imagen</td></tr>';
$html .= ' <tr><td><code>show_excerpt</code></td><td>true</td><td>Mostrar extracto</td></tr>';
$html .= ' <tr><td><code>show_meta</code></td><td>true</td><td>Fecha y autor</td></tr>';
$html .= ' <tr><td><code>show_categories</code></td><td>true</td><td>Badges categoria</td></tr>';
$html .= ' <tr><td><code>excerpt_length</code></td><td>20</td><td>Palabras extracto</td></tr>';
$html .= ' <tr><td><code>exclude_posts</code></td><td>-</td><td>IDs separados por coma</td></tr>';
$html .= ' <tr><td><code>offset</code></td><td>0</td><td>Saltar N posts</td></tr>';
$html .= ' <tr><td><code>id</code></td><td>-</td><td>ID unico (multiples grids)</td></tr>';
$html .= ' <tr><td><code>class</code></td><td>-</td><td>Clase CSS adicional</td></tr>';
$html .= ' </tbody>';
$html .= ' </table>';
$html .= ' </div>';
$html .= ' </div>';
$html .= '</div>';
return $html;
}
}

View File

@@ -152,10 +152,11 @@ final class PostGridRenderer implements RendererInterface
$paginationActiveColor = $colors['pagination_active_color'] ?? '#ffffff';
// Spacing
$gridGap = $spacing['grid_gap'] ?? '1.5rem';
$cardPadding = $spacing['card_padding'] ?? '1.25rem';
$sectionMarginTop = $spacing['section_margin_top'] ?? '0';
$sectionMarginBottom = $spacing['section_margin_bottom'] ?? '2rem';
$gapHorizontal = $spacing['gap_horizontal'] ?? '24px';
$gapVertical = $spacing['gap_vertical'] ?? '24px';
$cardPadding = $spacing['card_padding'] ?? '20px';
$sectionMarginTop = $spacing['section_margin_top'] ?? '0px';
$sectionMarginBottom = $spacing['section_margin_bottom'] ?? '32px';
// Visual effects
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
@@ -176,13 +177,23 @@ final class PostGridRenderer implements RendererInterface
'margin-bottom' => $sectionMarginBottom,
]);
// Row gap
$cssRules[] = $this->cssGenerator->generate('.post-grid .row', [
'gap' => $gridGap,
'row-gap' => $gridGap,
]);
// Row: usar display flex con gap, quitar margins/paddings de Bootstrap
$cssRules[] = ".post-grid .row {
display: flex;
flex-wrap: wrap;
column-gap: {$gapHorizontal};
row-gap: {$gapVertical};
margin: 0;
padding: 0;
}";
// Card base
// Columnas: quitar padding de Bootstrap y margin-bottom
$cssRules[] = ".post-grid .post-card-col {
padding: 0;
margin: 0;
}";
// Card base - sin margin extra
$cssRules[] = ".post-grid .card {
background: {$cardBgColor};
border: 1px solid {$cardBorderColor};
@@ -191,6 +202,7 @@ final class PostGridRenderer implements RendererInterface
transition: {$cardTransition};
height: 100%;
overflow: hidden;
margin: 0;
}";
// Card hover
@@ -318,8 +330,8 @@ final class PostGridRenderer implements RendererInterface
$colsTablet = $layout['columns_tablet'] ?? '2';
$colsMobile = $layout['columns_mobile'] ?? '1';
// Mobile
$mobileWidth = $this->getColumnWidth($colsMobile);
// Mobile (1 col = no gap needed)
$mobileWidth = $this->getColumnWidth($colsMobile, $gapHorizontal);
$cssRules[] = "@media (max-width: 575.98px) {
.post-grid .post-card-col {
flex: 0 0 {$mobileWidth};
@@ -328,7 +340,7 @@ final class PostGridRenderer implements RendererInterface
}";
// Tablet
$tabletWidth = $this->getColumnWidth($colsTablet);
$tabletWidth = $this->getColumnWidth($colsTablet, $gapHorizontal);
$cssRules[] = "@media (min-width: 576px) and (max-width: 991.98px) {
.post-grid .post-card-col {
flex: 0 0 {$tabletWidth};
@@ -337,7 +349,7 @@ final class PostGridRenderer implements RendererInterface
}";
// Desktop
$desktopWidth = $this->getColumnWidth($colsDesktop);
$desktopWidth = $this->getColumnWidth($colsDesktop, $gapHorizontal);
$cssRules[] = "@media (min-width: 992px) {
.post-grid .post-card-col {
flex: 0 0 {$desktopWidth};
@@ -348,14 +360,33 @@ final class PostGridRenderer implements RendererInterface
return implode("\n", $cssRules);
}
private function getColumnWidth(string $cols): string
/**
* Calcula el ancho de columna considerando el gap
*
* Con gap en flexbox, el ancho debe ser:
* (100% - (n-1)*gap) / n
*
* @param string $cols Número de columnas
* @param string $gap Valor del gap (ej: '1.5rem', '24px')
* @return string Valor CSS con calc() si hay gap
*/
private function getColumnWidth(string $cols, string $gap): string
{
$colCount = (int)$cols;
if ($colCount <= 0) {
$colCount = 1;
}
$percentage = 100 / $colCount;
return sprintf('%.4f%%', $percentage);
// Si es 1 columna, no hay gap entre columnas
if ($colCount === 1) {
return '100%';
}
// Número de gaps = columnas - 1
$gapCount = $colCount - 1;
// calc((100% - (n-1)*gap) / n)
return sprintf('calc((100%% - %d * %s) / %d)', $gapCount, $gap, $colCount);
}
private function buildHTML(array $data, string $visibilityClass): string
@@ -558,15 +589,63 @@ final class PostGridRenderer implements RendererInterface
private function buildPaginationHTML(): string
{
ob_start();
global $wp_query;
the_posts_pagination([
'mid_size' => 2,
'prev_text' => __('&laquo; Anterior', 'roi-theme'),
'next_text' => __('Siguiente &raquo;', 'roi-theme'),
'screen_reader_text' => __('Navegacion de publicaciones', 'roi-theme'),
]);
$totalPages = $wp_query->max_num_pages;
if ($totalPages <= 1) {
return '';
}
return ob_get_clean();
$currentPage = max(1, get_query_var('paged', 1));
$html = '<nav aria-label="Paginacion"><ul class="pagination justify-content-center">';
// Boton Inicio (siempre visible)
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Inicio</a></li>',
esc_url(get_pagenum_link(1))
);
// Numeros de pagina - mostrar 5 paginas
$visiblePages = 5;
$start = max(1, $currentPage - 2);
$end = min($totalPages, $start + $visiblePages - 1);
// Ajustar inicio si estamos cerca del final
if ($end - $start < $visiblePages - 1) {
$start = max(1, $end - $visiblePages + 1);
}
for ($i = $start; $i <= $end; $i++) {
if ($i === $currentPage) {
$html .= sprintf(
'<li class="page-item active"><span class="page-link">%d</span></li>',
$i
);
} else {
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">%d</a></li>',
esc_url(get_pagenum_link($i)),
$i
);
}
}
// Ver mas (siguiente pagina)
if ($currentPage < $totalPages) {
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Ver mas</a></li>',
esc_url(get_pagenum_link($currentPage + 1))
);
}
// Boton Fin (siempre visible)
$html .= sprintf(
'<li class="page-item"><a class="page-link" href="%s">Fin</a></li>',
esc_url(get_pagenum_link($totalPages))
);
$html .= '</ul></nav>';
return $html;
}
}

View File

@@ -111,8 +111,23 @@ final class TableOfContentsRenderer implements RendererInterface
return [];
}
// Intentar primero con contenido filtrado (respeta shortcodes, etc.)
$content = apply_filters('the_content', $post->post_content);
// Verificar si el contenido filtrado tiene headings
$hasFilteredHeadings = preg_match('/<h[2-6][^>]*>/i', $content);
// FIX: Si el contenido filtrado no tiene headings pero el raw si,
// usar el contenido raw. Esto ocurre cuando plugins como Thrive
// transforman el contenido para usuarios no logueados.
if (!$hasFilteredHeadings) {
$hasRawHeadings = preg_match('/<h[2-6][^>]*>/i', $post->post_content);
if ($hasRawHeadings) {
// Usar wpautop para dar formato basico al contenido raw
$content = wpautop($post->post_content);
}
}
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $content);

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Application\UseCases\RenderPostGrid;
/**
* RenderPostGridRequest - DTO de entrada para renderizar post grid shortcode
*
* RESPONSABILIDAD: Encapsular todos los atributos del shortcode [roi_post_grid]
*
* USO:
* ```php
* $request = RenderPostGridRequest::fromArray([
* 'category' => 'precios-unitarios',
* 'posts_per_page' => 6,
* 'columns' => 3
* ]);
* ```
*
* @package ROITheme\Shared\Application\UseCases\RenderPostGrid
*/
final readonly class RenderPostGridRequest
{
public function __construct(
public string $category = '',
public string $excludeCategory = '',
public string $tag = '',
public string $author = '',
public int $postsPerPage = 9,
public int $columns = 3,
public string $orderby = 'date',
public string $order = 'DESC',
public bool $showPagination = false,
public int $offset = 0,
public string $excludePosts = '',
public bool $showThumbnail = true,
public bool $showExcerpt = true,
public bool $showMeta = true,
public bool $showCategories = true,
public int $excerptLength = 20,
public string $class = '',
public string $id = '',
public int $paged = 1
) {}
/**
* Factory method: Crear desde array de atributos del shortcode
*
* @param array<string, mixed> $atts Atributos sanitizados del shortcode
* @return self
*/
public static function fromArray(array $atts): self
{
return new self(
category: (string) ($atts['category'] ?? ''),
excludeCategory: (string) ($atts['exclude_category'] ?? ''),
tag: (string) ($atts['tag'] ?? ''),
author: (string) ($atts['author'] ?? ''),
postsPerPage: self::clampInt((int) ($atts['posts_per_page'] ?? 9), 1, 50),
columns: self::clampInt((int) ($atts['columns'] ?? 3), 1, 4),
orderby: self::sanitizeOrderby((string) ($atts['orderby'] ?? 'date')),
order: self::sanitizeOrder((string) ($atts['order'] ?? 'DESC')),
showPagination: self::toBool($atts['show_pagination'] ?? false),
offset: max(0, (int) ($atts['offset'] ?? 0)),
excludePosts: (string) ($atts['exclude_posts'] ?? ''),
showThumbnail: self::toBool($atts['show_thumbnail'] ?? true),
showExcerpt: self::toBool($atts['show_excerpt'] ?? true),
showMeta: self::toBool($atts['show_meta'] ?? true),
showCategories: self::toBool($atts['show_categories'] ?? true),
excerptLength: max(1, (int) ($atts['excerpt_length'] ?? 20)),
class: (string) ($atts['class'] ?? ''),
id: (string) ($atts['id'] ?? ''),
paged: max(1, (int) ($atts['paged'] ?? 1))
);
}
/**
* Convertir a array de parametros para QueryBuilder
*
* @return array<string, mixed>
*/
public function toQueryParams(): array
{
return [
'category' => $this->category,
'exclude_category' => $this->excludeCategory,
'tag' => $this->tag,
'author' => $this->author,
'posts_per_page' => $this->postsPerPage,
'orderby' => $this->orderby,
'order' => $this->order,
'offset' => $this->offset,
'exclude_posts' => $this->excludePosts,
'paged' => $this->paged,
];
}
/**
* Convertir a array de opciones para Renderer
*
* @return array<string, mixed>
*/
public function toRenderOptions(): array
{
return [
'columns' => $this->columns,
'show_thumbnail' => $this->showThumbnail,
'show_excerpt' => $this->showExcerpt,
'show_meta' => $this->showMeta,
'show_categories' => $this->showCategories,
'excerpt_length' => $this->excerptLength,
'class' => $this->class,
'id' => $this->id,
'show_pagination' => $this->showPagination,
'posts_per_page' => $this->postsPerPage,
];
}
private static function clampInt(int $value, int $min, int $max): int
{
return max($min, min($max, $value));
}
private static function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return $value === 'true' || $value === '1' || $value === 1;
}
private static function sanitizeOrderby(string $value): string
{
$allowed = ['date', 'title', 'modified', 'rand', 'comment_count'];
return in_array($value, $allowed, true) ? $value : 'date';
}
private static function sanitizeOrder(string $value): string
{
$upper = strtoupper($value);
return in_array($upper, ['ASC', 'DESC'], true) ? $upper : 'DESC';
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Application\UseCases\RenderPostGrid;
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Shared\Domain\Constants\VisibilityDefaults;
/**
* Caso de uso: Renderizar grid de posts para shortcode
*
* RESPONSABILIDAD: Orquestar la obtencion de settings, construccion de query
* y renderizado del grid. No contiene logica de negocio, solo coordinacion.
*
* @package ROITheme\Shared\Application\UseCases\RenderPostGrid
*/
final class RenderPostGridUseCase
{
private const COMPONENT_NAME = 'post-grid';
public function __construct(
private readonly PostGridQueryBuilderInterface $queryBuilder,
private readonly PostGridShortcodeRendererInterface $renderer,
private readonly ComponentSettingsRepositoryInterface $settingsRepository
) {}
/**
* Ejecuta el caso de uso: obtiene settings, construye query y renderiza
*
* @param RenderPostGridRequest $request DTO con atributos del shortcode
* @return string HTML del grid renderizado
*/
public function execute(RenderPostGridRequest $request): string
{
// 1. Obtener settings del componente post-grid desde BD
$settings = $this->getSettings();
// 2. Construir query con los parametros del shortcode
$query = $this->queryBuilder->build($request->toQueryParams());
// 3. Renderizar grid con query, settings y opciones
$html = $this->renderer->render(
$query,
$settings,
$request->toRenderOptions()
);
// 4. Limpiar query (importante para evitar conflictos)
wp_reset_postdata();
return $html;
}
/**
* Obtiene settings del componente post-grid
*
* @return array<string, mixed>
*/
private function getSettings(): array
{
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
if (empty($settings)) {
return VisibilityDefaults::getForComponent(self::COMPONENT_NAME);
}
return $settings;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface PostGridQueryBuilderInterface
*
* Contrato para construccion de queries de posts para el shortcode post-grid.
* Define el comportamiento esperado para construir WP_Query sin depender
* de implementaciones especificas.
*
* Responsabilidades:
* - Construir WP_Query a partir de parametros de filtro
* - Aplicar filtros por categoria, tag, autor
* - Configurar paginacion y ordenamiento
*
* NO responsable de:
* - Generar HTML
* - Generar CSS
* - Obtener settings de BD
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface PostGridQueryBuilderInterface
{
/**
* Construye un WP_Query configurado con los parametros proporcionados.
*
* Ejemplo:
* ```php
* $params = [
* 'category' => 'precios-unitarios',
* 'tag' => 'concreto',
* 'posts_per_page' => 6,
* 'orderby' => 'date',
* 'order' => 'DESC'
* ];
*
* $query = $builder->build($params);
* ```
*
* @param array<string, mixed> $params Parametros de filtro y configuracion
* Keys soportadas: category, exclude_category, tag,
* author, posts_per_page, orderby, order, offset,
* exclude_posts, paged
*
* @return \WP_Query Query configurado listo para iterar
*/
public function build(array $params): \WP_Query;
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface PostGridShortcodeRendererInterface
*
* Contrato para renderizado HTML del shortcode post-grid.
* Define el comportamiento esperado para generar el HTML del grid
* sin depender de implementaciones especificas.
*
* Responsabilidades:
* - Generar HTML del grid de posts
* - Generar CSS inline usando CSSGeneratorInterface
* - Aplicar clases responsive de Bootstrap
*
* NO responsable de:
* - Construir queries
* - Obtener settings de BD
* - Sanitizar atributos del shortcode
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface PostGridShortcodeRendererInterface
{
/**
* Renderiza el grid de posts como HTML.
*
* Ejemplo:
* ```php
* $html = $renderer->render($query, $settings, [
* 'columns' => 3,
* 'show_thumbnail' => true,
* 'show_excerpt' => true,
* 'id' => 'grid-cursos'
* ]);
* ```
*
* @param \WP_Query $query Query con los posts a mostrar
* @param array<string, mixed> $settings Settings del componente post-grid desde BD
* @param array<string, mixed> $options Opciones de visualizacion del shortcode
* Keys: columns, show_thumbnail, show_excerpt,
* show_meta, show_categories, excerpt_length,
* class, id, show_pagination
*
* @return string HTML completo del grid incluyendo CSS inline
*/
public function render(\WP_Query $query, array $settings, array $options): string;
}

View File

@@ -42,6 +42,12 @@ use ROITheme\Shared\Domain\Contracts\WrapperVisibilityCheckerInterface;
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressComponentVisibilityRepository;
use ROITheme\Shared\Application\UseCases\CheckWrapperVisibilityUseCase;
use ROITheme\Shared\Infrastructure\Wordpress\BodyClassHooksRegistrar;
// Post Grid Shortcode (Plan post-grid-shortcode)
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
use ROITheme\Shared\Infrastructure\Query\PostGridQueryBuilder;
use ROITheme\Shared\Infrastructure\Ui\PostGridShortcodeRenderer;
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridUseCase;
/**
* DIContainer - Contenedor de Inyección de Dependencias
@@ -493,4 +499,47 @@ final class DIContainer
}
return $this->instances['bodyClassHooksRegistrar'];
}
// ===============================
// Post Grid Shortcode System
// ===============================
/**
* Obtiene el query builder para post grid shortcode
*/
public function getPostGridQueryBuilder(): PostGridQueryBuilderInterface
{
if (!isset($this->instances['postGridQueryBuilder'])) {
$this->instances['postGridQueryBuilder'] = new PostGridQueryBuilder();
}
return $this->instances['postGridQueryBuilder'];
}
/**
* Obtiene el renderer para post grid shortcode
*/
public function getPostGridShortcodeRenderer(): PostGridShortcodeRendererInterface
{
if (!isset($this->instances['postGridShortcodeRenderer'])) {
$this->instances['postGridShortcodeRenderer'] = new PostGridShortcodeRenderer(
$this->getCSSGeneratorService()
);
}
return $this->instances['postGridShortcodeRenderer'];
}
/**
* Obtiene el caso de uso para renderizar post grid shortcode
*/
public function getRenderPostGridUseCase(): RenderPostGridUseCase
{
if (!isset($this->instances['renderPostGridUseCase'])) {
$this->instances['renderPostGridUseCase'] = new RenderPostGridUseCase(
$this->getPostGridQueryBuilder(),
$this->getPostGridShortcodeRenderer(),
$this->getComponentSettingsRepository()
);
}
return $this->instances['renderPostGridUseCase'];
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Hooks;
/**
* Registra hooks para arquitectura cache-first.
*
* Permite que plugins externos evalúen condiciones ANTES de servir páginas,
* sin bloquear el cache de WordPress.
*
* @see openspec/specs/cache-first-architecture/spec.md
* @package ROITheme\Shared\Infrastructure\Hooks
*/
final class CacheFirstHooksRegistrar
{
/**
* Registra los hooks de cache-first.
*/
public function register(): void
{
add_action('template_redirect', [$this, 'fireBeforePageServe'], 0);
}
/**
* Dispara hook para que plugins externos evalúen acceso.
*
* Solo se dispara para:
* - Páginas singulares (posts, pages, CPTs)
* - Visitantes NO logueados (cache no aplica a usuarios logueados)
*
* Los plugins pueden llamar wp_safe_redirect() + exit para bloquear.
* Si no hacen nada, la página se sirve normalmente (con cache si disponible).
*/
public function fireBeforePageServe(): void
{
// No para usuarios logueados (cache no aplica, no tiene sentido evaluar)
if (is_user_logged_in()) {
return;
}
// Solo páginas singulares
if (!is_singular()) {
return;
}
// No en admin/ajax/cron/REST
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
return;
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return;
}
$post_id = get_queried_object_id();
if ($post_id > 0) {
/**
* Hook: roi_theme_before_page_serve
*
* Permite que plugins externos evalúen condiciones antes de servir página.
*
* Uso típico:
* - Rate limiters (límite de vistas por IP)
* - Membership plugins (verificar acceso)
* - Geolocation restrictions
*
* Para bloquear acceso:
* wp_safe_redirect('/pagina-destino/', 302);
* exit;
*
* Para permitir acceso:
* return; // La página se servirá (con cache si disponible)
*
* @since 1.0.0
* @param int $post_id ID del post/page que se va a servir
*/
do_action('roi_theme_before_page_serve', $post_id);
}
}
}

View File

@@ -61,15 +61,35 @@ final class WordPressComponentVisibilityRepository implements WrapperVisibilityC
/**
* {@inheritDoc}
*
* Delega a PageVisibilityHelper que ya implementa:
* Evalúa múltiples criterios de exclusión:
* - hide_for_logged_in: Ocultar para usuarios logueados
* - Visibilidad por tipo de página (home, posts, pages, archives, search)
* - Exclusiones por categoría, post ID, URL pattern
*/
public function isNotExcluded(string $componentName): bool
{
// Verificar hide_for_logged_in
if ($this->shouldHideForLoggedIn($componentName)) {
return false;
}
return PageVisibilityHelper::shouldShow($componentName);
}
/**
* Verifica si debe ocultarse para usuarios logueados
*/
private function shouldHideForLoggedIn(string $componentName): bool
{
$value = $this->getVisibilityAttribute($componentName, 'hide_for_logged_in');
if ($value === null) {
return false;
}
return $this->toBool($value) && is_user_logged_in();
}
/**
* Obtiene un atributo del grupo visibility desde la BD
*

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Query;
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
/**
* Implementacion de PostGridQueryBuilderInterface
*
* RESPONSABILIDAD: Construir WP_Query a partir de parametros de filtro.
* No genera HTML ni obtiene settings.
*
* @package ROITheme\Shared\Infrastructure\Query
*/
final class PostGridQueryBuilder implements PostGridQueryBuilderInterface
{
/**
* {@inheritdoc}
*/
public function build(array $params): \WP_Query
{
$args = [
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => (int) ($params['posts_per_page'] ?? 9),
'orderby' => $params['orderby'] ?? 'date',
'order' => $params['order'] ?? 'DESC',
'paged' => (int) ($params['paged'] ?? 1),
];
// Offset
if (!empty($params['offset'])) {
$args['offset'] = (int) $params['offset'];
}
// Filtro por categoria(s)
if (!empty($params['category'])) {
$args['category_name'] = $this->sanitizeSlugs($params['category']);
}
// Excluir categoria(s)
if (!empty($params['exclude_category'])) {
$excludeIds = $this->getCategoryIds($params['exclude_category']);
if (!empty($excludeIds)) {
$args['category__not_in'] = $excludeIds;
}
}
// Filtro por tag(s)
if (!empty($params['tag'])) {
$args['tag'] = $this->sanitizeSlugs($params['tag']);
}
// Filtro por autor
if (!empty($params['author'])) {
$author = $params['author'];
if (is_numeric($author)) {
$args['author'] = (int) $author;
} else {
$user = get_user_by('login', $author);
if ($user) {
$args['author'] = $user->ID;
}
}
}
// Excluir posts por ID
if (!empty($params['exclude_posts'])) {
$excludeIds = array_map('intval', explode(',', $params['exclude_posts']));
$excludeIds = array_filter($excludeIds, fn($id) => $id > 0);
if (!empty($excludeIds)) {
$args['post__not_in'] = $excludeIds;
}
}
return new \WP_Query($args);
}
/**
* Sanitiza slugs separados por coma
*/
private function sanitizeSlugs(string $slugs): string
{
$parts = explode(',', $slugs);
$sanitized = array_map('sanitize_title', $parts);
return implode(',', $sanitized);
}
/**
* Obtiene IDs de categorias desde slugs
*
* @return int[]
*/
private function getCategoryIds(string $slugs): array
{
$parts = explode(',', $slugs);
$ids = [];
foreach ($parts as $slug) {
$term = get_term_by('slug', trim($slug), 'category');
if ($term instanceof \WP_Term) {
$ids[] = $term->term_id;
}
}
return $ids;
}
}

View File

@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
/**
* Implementacion de PostGridShortcodeRendererInterface
*
* RESPONSABILIDAD: Generar HTML y CSS del shortcode [roi_post_grid].
* No construye queries ni obtiene settings de BD.
*
* @package ROITheme\Shared\Infrastructure\Ui
*/
final class PostGridShortcodeRenderer implements PostGridShortcodeRendererInterface
{
public function __construct(
private readonly CSSGeneratorInterface $cssGenerator
) {}
/**
* {@inheritdoc}
*/
public function render(\WP_Query $query, array $settings, array $options): string
{
if (!$query->have_posts()) {
return $this->renderNoPostsMessage($settings, $options);
}
$css = $this->generateCSS($settings, $options);
$html = $this->buildHTML($query, $settings, $options);
return sprintf("<style>%s</style>\n%s", $css, $html);
}
private function renderNoPostsMessage(array $settings, array $options): string
{
$colors = $settings['colors'] ?? [];
$message = 'No se encontraron publicaciones';
$bgColor = $colors['card_bg_color'] ?? '#ffffff';
$textColor = $colors['excerpt_color'] ?? '#6b7280';
$borderColor = $colors['card_border_color'] ?? '#e5e7eb';
$selector = $this->getSelector($options);
$css = $this->cssGenerator->generate("{$selector} .no-posts", [
'background-color' => $bgColor,
'color' => $textColor,
'border' => "1px solid {$borderColor}",
'border-radius' => '0.5rem',
'padding' => '2rem',
'text-align' => 'center',
]);
$containerClass = $this->getContainerClass($options);
return sprintf(
"<style>%s</style>\n<div class=\"%s\"><div class=\"no-posts\"><p class=\"mb-0\">%s</p></div></div>",
$css,
esc_attr($containerClass),
esc_html($message)
);
}
private function getSelector(array $options): string
{
$id = $options['id'] ?? '';
return !empty($id)
? ".roi-post-grid-shortcode-{$id}"
: '.roi-post-grid-shortcode';
}
private function getContainerClass(array $options): string
{
$id = $options['id'] ?? '';
$customClass = $options['class'] ?? '';
$class = !empty($id)
? "roi-post-grid-shortcode roi-post-grid-shortcode-{$id}"
: 'roi-post-grid-shortcode';
if (!empty($customClass)) {
$class .= ' ' . sanitize_html_class($customClass);
}
return $class;
}
private function generateCSS(array $settings, array $options): string
{
$colors = $settings['colors'] ?? [];
$spacing = $settings['spacing'] ?? [];
$effects = $settings['visual_effects'] ?? [];
$typography = $settings['typography'] ?? [];
$selector = $this->getSelector($options);
$cssRules = [];
// Colores
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
$cardTitleColor = $colors['card_title_color'] ?? '#0E2337';
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? '#f9fafb';
$cardBorderColor = $colors['card_border_color'] ?? '#e5e7eb';
$cardHoverBorderColor = $colors['card_hover_border_color'] ?? '#FF8600';
$excerptColor = $colors['excerpt_color'] ?? '#6b7280';
$metaColor = $colors['meta_color'] ?? '#9ca3af';
$categoryBgColor = $colors['category_bg_color'] ?? '#FFF5EB';
$categoryTextColor = $colors['category_text_color'] ?? '#FF8600';
// Spacing
$gridGap = $spacing['grid_gap'] ?? '1.5rem';
$cardPadding = $spacing['card_padding'] ?? '1.25rem';
// Visual effects
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
$cardShadow = $effects['card_shadow'] ?? '0 1px 3px rgba(0,0,0,0.1)';
$cardHoverShadow = $effects['card_hover_shadow'] ?? '0 4px 12px rgba(0,0,0,0.15)';
$cardTransition = $effects['card_transition'] ?? 'all 0.3s ease';
// Typography
$cardTitleSize = $typography['card_title_size'] ?? '1.1rem';
$cardTitleWeight = $typography['card_title_weight'] ?? '600';
$excerptSize = $typography['excerpt_size'] ?? '0.9rem';
$metaSize = $typography['meta_size'] ?? '0.8rem';
// Container
$cssRules[] = $this->cssGenerator->generate($selector, [
'margin-bottom' => '2rem',
]);
// Row
$cssRules[] = $this->cssGenerator->generate("{$selector} .row", [
'row-gap' => $gridGap,
]);
// Card
$cssRules[] = "{$selector} .card {
background: {$cardBgColor};
border: 1px solid {$cardBorderColor};
border-radius: {$cardBorderRadius};
box-shadow: {$cardShadow};
transition: {$cardTransition};
height: 100%;
}";
$cssRules[] = "{$selector} .card:hover {
background: {$cardHoverBgColor};
border-color: {$cardHoverBorderColor};
box-shadow: {$cardHoverShadow};
transform: translateY(-2px);
}";
$cssRules[] = $this->cssGenerator->generate("{$selector} .card-body", [
'padding' => $cardPadding,
]);
$cssRules[] = "{$selector} .card-img-top {
border-radius: {$cardBorderRadius} {$cardBorderRadius} 0 0;
object-fit: cover;
width: 100%;
height: 200px;
}";
$cssRules[] = "{$selector} .card-title {
color: {$cardTitleColor};
font-size: {$cardTitleSize};
font-weight: {$cardTitleWeight};
line-height: 1.4;
margin-bottom: 0.75rem;
}";
$cssRules[] = "{$selector} a:hover .card-title {
color: {$cardHoverBorderColor};
}";
$cssRules[] = "{$selector} .card-text {
color: {$excerptColor};
font-size: {$excerptSize};
line-height: 1.6;
}";
$cssRules[] = "{$selector} .post-meta {
color: {$metaColor};
font-size: {$metaSize};
}";
$cssRules[] = "{$selector} .post-category {
background: {$categoryBgColor};
color: {$categoryTextColor};
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}";
return implode("\n", $cssRules);
}
private function buildHTML(\WP_Query $query, array $settings, array $options): string
{
$columns = (int) ($options['columns'] ?? 3);
$showThumbnail = $this->toBool($options['show_thumbnail'] ?? true);
$showExcerpt = $this->toBool($options['show_excerpt'] ?? true);
$showMeta = $this->toBool($options['show_meta'] ?? true);
$showCategories = $this->toBool($options['show_categories'] ?? true);
$excerptLength = (int) ($options['excerpt_length'] ?? 20);
$showPagination = $this->toBool($options['show_pagination'] ?? false);
$containerClass = $this->getContainerClass($options);
$colClass = $this->getColumnClass($columns);
$html = sprintf('<div class="%s">', esc_attr($containerClass));
$html .= '<div class="row">';
while ($query->have_posts()) {
$query->the_post();
$html .= $this->buildCardHTML(
$colClass,
$showThumbnail,
$showExcerpt,
$showMeta,
$showCategories,
$excerptLength
);
}
$html .= '</div>';
if ($showPagination && $query->max_num_pages > 1) {
$html .= $this->buildPaginationHTML($query, $options);
}
$html .= '</div>';
return $html;
}
private function getColumnClass(int $columns): string
{
return match ($columns) {
1 => 'col-12',
2 => 'col-12 col-md-6',
4 => 'col-12 col-md-6 col-lg-3',
default => 'col-12 col-md-6 col-lg-4',
};
}
private function buildCardHTML(
string $colClass,
bool $showThumbnail,
bool $showExcerpt,
bool $showMeta,
bool $showCategories,
int $excerptLength
): string {
$permalink = get_permalink();
$title = get_the_title();
$html = sprintf('<div class="%s mb-4">', esc_attr($colClass));
$html .= sprintf('<a href="%s" class="text-decoration-none">', esc_url($permalink));
$html .= '<div class="card h-100">';
if ($showThumbnail) {
$html .= $this->buildImageHTML();
}
$html .= '<div class="card-body">';
if ($showCategories) {
$html .= $this->buildCategoriesHTML();
}
$html .= sprintf('<h3 class="card-title">%s</h3>', esc_html($title));
if ($showMeta) {
$html .= $this->buildMetaHTML();
}
if ($showExcerpt) {
$html .= $this->buildExcerptHTML($excerptLength);
}
$html .= '</div></div></a></div>';
return $html;
}
private function buildImageHTML(): string
{
if (has_post_thumbnail()) {
return get_the_post_thumbnail(null, 'medium_large', [
'class' => 'card-img-top',
'loading' => 'lazy'
]);
}
return '';
}
private function buildCategoriesHTML(): string
{
$categories = get_the_category();
if (empty($categories)) {
return '';
}
$html = '<div class="post-categories mb-2">';
foreach (array_slice($categories, 0, 2) as $category) {
$html .= sprintf(
'<span class="post-category">%s</span>',
esc_html($category->name)
);
}
$html .= '</div>';
return $html;
}
private function buildMetaHTML(): string
{
return sprintf(
'<div class="post-meta mb-2"><small>%s | %s</small></div>',
esc_html(get_the_date()),
esc_html(get_the_author())
);
}
private function buildExcerptHTML(int $length): string
{
$excerpt = get_the_excerpt();
if (empty($excerpt)) {
$excerpt = wp_trim_words(get_the_content(), $length, '...');
} else {
$excerpt = wp_trim_words($excerpt, $length, '...');
}
return sprintf('<p class="card-text">%s</p>', esc_html($excerpt));
}
private function buildPaginationHTML(\WP_Query $query, array $options): string
{
$id = $options['id'] ?? '';
$queryVar = !empty($id) ? "paged_{$id}" : 'paged';
$currentPage = max(1, (int) get_query_var($queryVar, 1));
$totalPages = $query->max_num_pages;
$html = '<nav class="pagination-wrapper mt-4"><ul class="pagination justify-content-center">';
for ($i = 1; $i <= $totalPages; $i++) {
$activeClass = ($i === $currentPage) ? ' active' : '';
$url = add_query_arg($queryVar, $i);
$html .= sprintf(
'<li class="page-item%s"><a class="page-link" href="%s">%d</a></li>',
$activeClass,
esc_url($url),
$i
);
}
$html .= '</ul></nav>';
return $html;
}
private function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return $value === 'true' || $value === '1' || $value === 1;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Wordpress;
use ROITheme\Shared\Infrastructure\Di\DIContainer;
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridRequest;
/**
* Registra el shortcode [roi_post_grid] en WordPress
*
* RESPONSABILIDAD:
* - Registrar shortcode via add_shortcode
* - Sanitizar atributos del shortcode
* - Delegar ejecucion a RenderPostGridUseCase
*
* NO RESPONSABLE DE:
* - Logica de negocio
* - Construccion de queries
* - Generacion de HTML/CSS
*
* @package ROITheme\Shared\Infrastructure\Wordpress
*/
final class PostGridShortcodeRegistrar
{
private const SHORTCODE_TAG = 'roi_post_grid';
/**
* Registra el shortcode en WordPress
*/
public static function register(): void
{
add_shortcode(self::SHORTCODE_TAG, [new self(), 'handleShortcode']);
}
/**
* Callback del shortcode
*
* @param array|string $atts Atributos del shortcode
* @return string HTML del grid
*/
public function handleShortcode($atts): string
{
$atts = $this->sanitizeAttributes($atts);
// Obtener paged desde query var si existe
$id = $atts['id'] ?? '';
$queryVar = !empty($id) ? "paged_{$id}" : 'paged';
$atts['paged'] = max(1, (int) get_query_var($queryVar, 1));
// Crear request DTO
$request = RenderPostGridRequest::fromArray($atts);
// Obtener UseCase desde DIContainer
$container = DIContainer::getInstance();
$useCase = $container->getRenderPostGridUseCase();
return $useCase->execute($request);
}
/**
* Sanitiza atributos del shortcode
*
* @param array|string $atts
* @return array<string, mixed>
*/
private function sanitizeAttributes($atts): array
{
$atts = shortcode_atts([
'category' => '',
'exclude_category' => '',
'tag' => '',
'author' => '',
'posts_per_page' => '9',
'columns' => '3',
'orderby' => 'date',
'order' => 'DESC',
'show_pagination' => 'false',
'offset' => '0',
'exclude_posts' => '',
'show_thumbnail' => 'true',
'show_excerpt' => 'true',
'show_meta' => 'true',
'show_categories' => 'true',
'excerpt_length' => '20',
'class' => '',
'id' => '',
], $atts, self::SHORTCODE_TAG);
// Sanitizar cada valor
return array_map(function ($value) {
return is_string($value) ? sanitize_text_field($value) : $value;
}, $atts);
}
}

View File

@@ -53,6 +53,27 @@ $main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
}
?>
<!-- Social Share - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('social-share');
}
?>
<!-- CTA Post - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-post');
}
?>
<!-- Related Post - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('related-post');
}
?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<?php if ($show_sidebar): ?>

View File

@@ -53,6 +53,27 @@ $main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
}
?>
<!-- Social Share - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('social-share');
}
?>
<!-- CTA Post - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('cta-post');
}
?>
<!-- Related Post - Componente dinamico -->
<?php
if (function_exists('roi_render_component')) {
echo roi_render_component('related-post');
}
?>
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
<?php if ($show_sidebar): ?>

View File

@@ -1,5 +1,27 @@
<?php
// =============================================================================
// ROI THEME DEBUG MODE
// =============================================================================
// Para activar el modo debug, agregar en wp-config.php:
// define('ROI_DEBUG', true);
//
// IMPORTANTE: Mantener desactivado en producción para evitar logs de GB.
// =============================================================================
if (!defined('ROI_DEBUG')) {
define('ROI_DEBUG', false);
}
/**
* Log de debug condicional para ROI Theme.
* Solo escribe al log si ROI_DEBUG está activado.
*/
function roi_debug_log(string $message): void {
if (ROI_DEBUG) {
error_log($message);
}
}
// =============================================================================
// AUTOLOADER PARA COMPONENTES
// =============================================================================
@@ -181,7 +203,7 @@ function roi_render_component(string $componentName): string {
global $wpdb;
// DEBUG: Trace component rendering
error_log("ROI Theme DEBUG: roi_render_component called with: {$componentName}");
roi_debug_log("ROI Theme DEBUG: roi_render_component called with: {$componentName}");
try {
// Obtener datos del componente desde BD normalizada
@@ -297,9 +319,9 @@ function roi_render_component(string $componentName): string {
$renderer = new \ROITheme\Public\Navbar\Infrastructure\Ui\NavbarRenderer($cssGenerator);
break;
case 'hero':
error_log("ROI Theme DEBUG: Creating HeroRenderer");
roi_debug_log("ROI Theme DEBUG: Creating HeroRenderer");
$renderer = new \ROITheme\Public\Hero\Infrastructure\Ui\HeroRenderer($cssGenerator);
error_log("ROI Theme DEBUG: HeroRenderer created successfully");
roi_debug_log("ROI Theme DEBUG: HeroRenderer created successfully");
break;
// Componentes sin soporte de CSS Crítico (below-the-fold)
@@ -339,13 +361,13 @@ function roi_render_component(string $componentName): string {
}
if (!$renderer) {
error_log("ROI Theme DEBUG: No renderer for {$componentName}");
roi_debug_log("ROI Theme DEBUG: No renderer for {$componentName}");
return '';
}
error_log("ROI Theme DEBUG: Calling render() for {$componentName}");
roi_debug_log("ROI Theme DEBUG: Calling render() for {$componentName}");
$output = $renderer->render($component);
error_log("ROI Theme DEBUG: render() returned " . strlen($output) . " chars for {$componentName}");
roi_debug_log("ROI Theme DEBUG: render() returned " . strlen($output) . " chars for {$componentName}");
return $output;
} catch (\Exception $e) {
@@ -562,3 +584,22 @@ function roi_get_adsense_search_config(): array {
],
];
}
// =============================================================================
// POST GRID SHORTCODE [roi_post_grid]
// =============================================================================
/**
* Registra el shortcode [roi_post_grid] para mostrar grids de posts
* en cualquier pagina o entrada.
*
* USO:
* [roi_post_grid]
* [roi_post_grid category="precios-unitarios" posts_per_page="6"]
* [roi_post_grid id="grid-1" category="cursos" show_pagination="true"]
*
* @see Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar
*/
add_action('init', function() {
\ROITheme\Shared\Infrastructure\Wordpress\PostGridShortcodeRegistrar::register();
});

View File

@@ -174,6 +174,12 @@ try {
);
$youtubeFacadeHooksRegistrar->register();
// === CACHE-FIRST ARCHITECTURE (Plan 1000.01) ===
// Hook para plugins externos que necesitan evaluar acceso antes de servir página
// @see openspec/specs/cache-first-architecture/spec.md
$cacheFirstHooksRegistrar = new \ROITheme\Shared\Infrastructure\Hooks\CacheFirstHooksRegistrar();
$cacheFirstHooksRegistrar->register();
// Log en modo debug
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('ROI Theme: Admin Panel initialized successfully');

View File

@@ -0,0 +1,154 @@
# Especificacion de Arquitectura Cache-First
## Purpose
Define la arquitectura cache-first para ROITheme que permite que plugins de cache (W3TC, Redis, etc.) funcionen correctamente mientras plugins externos pueden evaluar condiciones ANTES de servir paginas.
Esta arquitectura:
- Permite que las paginas se sirvan desde cache siempre que sea posible
- Provee hooks para que plugins externos (rate limiters, access control, etc.) evaluen condiciones
- Desacopla el tema de plugins especificos de restriccion de acceso
- Es portable: cualquier sitio con roi-theme tiene esta capacidad
## Requirements
### Requirement: Hook de Pre-Evaluacion de Pagina
The system MUST provide a hook that fires BEFORE WordPress serves a page, allowing external plugins to evaluate conditions and potentially redirect.
#### Scenario: Plugin externo necesita evaluar acceso antes de servir pagina
- **GIVEN** un plugin de control de acceso (rate limiter, membership, etc.)
- **WHEN** un visitante solicita una pagina singular (post, page, CPT)
- **THEN** el tema DEBE disparar `do_action('roi_theme_before_page_serve', $post_id)`
- **AND** el hook DEBE ejecutarse en `template_redirect` con priority 0
- **AND** si el plugin llama `wp_safe_redirect()` y `exit`, la pagina NO se sirve
#### Scenario: Ningn plugin enganchado al hook
- **GIVEN** ningun plugin esta escuchando `roi_theme_before_page_serve`
- **WHEN** un visitante solicita una pagina
- **THEN** la pagina se sirve normalmente (con cache si disponible)
- **AND** no hay impacto en rendimiento
#### Scenario: Hook solo dispara en paginas singulares
- **GIVEN** el hook `roi_theme_before_page_serve`
- **WHEN** la solicitud es para archivo, home, search, feed, o admin
- **THEN** el hook NO DEBE dispararse
- **AND** la pagina se sirve sin evaluacion adicional
#### Scenario: Hook no dispara para usuarios logueados
- **GIVEN** un usuario autenticado (logged in)
- **WHEN** solicita cualquier pagina
- **THEN** el hook NO DEBE dispararse
- **AND** la pagina se sirve directamente sin evaluacion
- **BECAUSE** WordPress no cachea paginas para usuarios logueados (cookies de sesion)
---
### Requirement: Contexto Rico para Plugins Externos
The system MUST provide sufficient context for external plugins to make access decisions.
#### Scenario: Plugin necesita informacion del post
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
- **WHEN** el hook se dispara
- **THEN** el plugin recibe `$post_id` como parametro
- **AND** `get_queried_object()` retorna el objeto WP_Post completo
- **AND** `is_singular()`, `is_single()`, `is_page()` funcionan correctamente
#### Scenario: Plugin necesita informacion del visitante
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
- **WHEN** el hook se dispara
- **THEN** `is_user_logged_in()` esta disponible
- **AND** `$_SERVER['REMOTE_ADDR']` esta disponible
- **AND** headers HTTP estan disponibles via `$_SERVER`
---
### Requirement: No Bloquear Cache
The system MUST NOT define DONOTCACHEPAGE or similar constants that prevent caching.
#### Scenario: Tema no interfiere con plugins de cache
- **GIVEN** el tema roi-theme instalado
- **WHEN** W3TC, WP Super Cache, o Redis Object Cache estan activos
- **THEN** el tema NO DEBE definir `DONOTCACHEPAGE`
- **AND** el tema NO DEBE definir `DONOTCACHEOBJECT`
- **AND** el tema NO DEBE enviar headers `Cache-Control: no-cache`
#### Scenario: Plugin externo decide bloquear cache
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
- **WHEN** el plugin necesita bloquear cache para una pagina especifica
- **THEN** es responsabilidad del PLUGIN definir `DONOTCACHEPAGE`
- **AND** el tema NO participa en esa decision
---
### Requirement: Ubicacion en Clean Architecture
The hook registration MUST follow ROITheme's Clean Architecture patterns.
#### Scenario: Hook registrado en Infrastructure
- **GIVEN** el sistema de hooks del tema
- **WHEN** el hook `roi_theme_before_page_serve` es registrado
- **THEN** el registro DEBE estar en `Shared/Infrastructure/Hooks/`
- **AND** DEBE seguir el patron de HooksRegistrar existente
#### Scenario: Servicio como punto de extension
- **GIVEN** la arquitectura del tema
- **WHEN** se necesita extender funcionalidad de pre-evaluacion
- **THEN** DEBE existir una interfaz en `Shared/Domain/Contracts/`
- **AND** la implementacion DEBE estar en `Shared/Infrastructure/Services/`
---
## Implementation Notes
### Hook Priority
```
template_redirect priority order:
├─ Priority 0: roi_theme_before_page_serve (tema dispara hook)
│ └─ Plugins externos se enganchan aqui
├─ Priority 1+: Otros plugins
└─ Priority 10 (default): WordPress template loading
```
### Ejemplo de Plugin Enganchado
```php
// En un plugin externo (ej: ip-rate-limiter)
add_action('roi_theme_before_page_serve', function(int $post_id) {
// Evaluar condicion (ej: limite de IP)
if ($this->is_limit_exceeded()) {
wp_safe_redirect('/suscripcion-vip/?reason=limit', 302);
exit;
}
// Si no hay problema, simplemente retornar
// La pagina se servira (con cache si disponible)
}, 10);
```
### Archivos a Crear
```
Shared/
├── Domain/
│ └── Contracts/
│ └── PageServeHookInterface.php # Interface del hook
└── Infrastructure/
└── Hooks/
└── CacheFirstHooksRegistrar.php # Registro del hook
```
---
## Acceptance Criteria
1. El hook `roi_theme_before_page_serve` se dispara en `template_redirect` priority 0
2. Solo se dispara para `is_singular() === true`
3. NO se dispara para usuarios logueados (`is_user_logged_in() === true`)
4. Pasa `$post_id` como parametro al hook
5. No define DONOTCACHEPAGE ni headers anti-cache
6. Plugins externos pueden enganchar y hacer redirect/exit
7. Si ningun plugin engancha, no hay impacto en rendimiento
8. Sigue patrones de Clean Architecture del tema

View File

@@ -0,0 +1,410 @@
# Especificacion de Shortcode Post Grid
## Purpose
Crear un shortcode `[roi_post_grid]` que permita insertar grids de posts en cualquier pagina o entrada de WordPress, con filtros configurables por categoria, tag, autor y otras opciones. Sigue Clean Architecture delegando logica a Use Cases.
## Context
### Problema Actual
El componente `post-grid` implementado en `templates-unificados` solo funciona en templates de archivo (home.php, archive.php, etc.) porque depende del loop principal de WordPress (`global $wp_query`).
### Solucion
Crear un shortcode que:
1. Siga Clean Architecture (ShortcodeRegistrar → UseCase → Repository)
2. Reutilice estilos del componente `post-grid` existente
3. Permita filtrar posts por categoria, tag, autor
4. No dependa del loop principal de WordPress
### Relacion con templates-unificados
- Este shortcode **complementa** (no reemplaza) el componente post-grid
- El componente post-grid sigue funcionando en templates de archivo
- El shortcode permite insertar grids en contenido arbitrario
### Nota sobre shortcodes legacy
Los shortcodes existentes (`apu_table`, `apu_row`) estan en `Inc/apu-tables.php` (patron legacy). Este nuevo shortcode sigue el patron moderno de Clean Architecture.
---
## Requirements
### Requirement: Arquitectura del Shortcode
The shortcode MUST follow Clean Architecture patterns.
#### Scenario: Ubicacion del ShortcodeRegistrar
- **WHEN** se implementa el shortcode
- **THEN** DEBE estar en `Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php`
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Wordpress`
- **AND** DEBE registrar el shortcode via add_shortcode
#### Scenario: Delegacion a Use Case
- **WHEN** el shortcode se ejecuta
- **THEN** PostGridShortcodeRegistrar DEBE delegar a RenderPostGridUseCase
- **AND** NO DEBE contener logica de negocio
- **AND** solo sanitiza atributos y pasa al Use Case
#### Scenario: Ubicacion del Use Case
- **WHEN** se implementa la logica del shortcode
- **THEN** DEBE estar en `Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php`
- **AND** DEBE usar namespace `ROITheme\Shared\Application\UseCases\RenderPostGrid`
- **AND** DEBE orquestar Query, Renderer y Settings
#### Scenario: Ubicacion del QueryBuilder
- **WHEN** se construye el WP_Query
- **THEN** DEBE estar en `Shared/Infrastructure/Query/PostGridQueryBuilder.php`
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Query`
- **AND** DEBE recibir parametros y retornar WP_Query
#### Scenario: Ubicacion del ShortcodeRenderer
- **WHEN** se genera el HTML del grid
- **THEN** DEBE estar en `Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php`
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Ui`
- **AND** DEBE inyectar CSSGeneratorInterface
---
### Requirement: Estructura de Clases
Each class MUST have single responsibility following SRP.
#### Scenario: Responsabilidad de PostGridShortcodeRegistrar
- **WHEN** se define PostGridShortcodeRegistrar
- **THEN** DEBE tener metodo estatico `register()` para add_shortcode
- **AND** DEBE tener metodo `handleShortcode(array $atts): string`
- **AND** handleShortcode DEBE sanitizar atributos
- **AND** handleShortcode DEBE obtener UseCase del DIContainer
- **AND** handleShortcode DEBE retornar resultado del UseCase
- **AND** NO DEBE tener mas de 50 lineas
#### Scenario: Responsabilidad de RenderPostGridUseCase
- **WHEN** se define RenderPostGridUseCase
- **THEN** DEBE recibir RenderPostGridRequest como input
- **AND** DEBE inyectar PostGridQueryBuilderInterface via constructor
- **AND** DEBE inyectar PostGridShortcodeRendererInterface via constructor
- **AND** DEBE inyectar ComponentSettingsRepositoryInterface via constructor
- **AND** DEBE orquestar: obtener settings, construir query, renderizar
- **AND** DEBE retornar string HTML
- **AND** NO DEBE tener mas de 80 lineas
#### Scenario: Responsabilidad de PostGridQueryBuilder
- **WHEN** se define PostGridQueryBuilder
- **THEN** DEBE recibir parametros de filtro (category, tag, etc.)
- **AND** DEBE construir array de argumentos WP_Query
- **AND** DEBE retornar WP_Query configurado
- **AND** NO DEBE generar HTML
- **AND** NO DEBE tener mas de 100 lineas
#### Scenario: Responsabilidad de PostGridShortcodeRenderer
- **WHEN** se define PostGridShortcodeRenderer
- **THEN** DEBE recibir WP_Query y configuracion
- **AND** DEBE inyectar CSSGeneratorInterface
- **AND** DEBE generar HTML del grid
- **AND** DEBE generar CSS inline
- **AND** NO DEBE construir queries
- **AND** NO DEBE tener mas de 150 lineas
---
### Requirement: Interfaces en Domain/Application
Interfaces MUST be defined for dependency injection.
#### Scenario: Interface del QueryBuilder
- **WHEN** se define la interface
- **THEN** DEBE estar en `Shared/Domain/Contracts/PostGridQueryBuilderInterface.php`
- **AND** DEBE tener metodo `build(array $params): \WP_Query`
#### Scenario: Interface del Renderer
- **WHEN** se define la interface
- **THEN** DEBE estar en `Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php`
- **AND** DEBE tener metodo `render(\WP_Query $query, array $settings, array $options): string`
#### Scenario: Request DTO
- **WHEN** se define el DTO de entrada
- **THEN** DEBE estar en `Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php`
- **AND** DEBE ser readonly class con propiedades tipadas
- **AND** DEBE contener todos los atributos del shortcode
---
### Requirement: Atributos del Shortcode
The shortcode MUST accept configurable attributes.
#### Scenario: Atributo category
- **WHEN** se usa `[roi_post_grid category="precios-unitarios"]`
- **THEN** DEBE filtrar posts por slug de categoria
- **AND** DEBE aceptar multiples categorias separadas por coma
- **AND** ejemplo: `category="cat1,cat2,cat3"`
#### Scenario: Atributo exclude_category
- **WHEN** se usa `[roi_post_grid exclude_category="noticias"]`
- **THEN** DEBE excluir posts de esas categorias
- **AND** DEBE aceptar multiples categorias separadas por coma
#### Scenario: Atributo tag
- **WHEN** se usa `[roi_post_grid tag="concreto"]`
- **THEN** DEBE filtrar posts por slug de tag
- **AND** DEBE aceptar multiples tags separados por coma
#### Scenario: Atributo author
- **WHEN** se usa `[roi_post_grid author="admin"]`
- **THEN** DEBE filtrar posts por login de autor
- **AND** DEBE aceptar ID numerico o login string
#### Scenario: Atributo posts_per_page
- **WHEN** se usa `[roi_post_grid posts_per_page="6"]`
- **THEN** DEBE limitar cantidad de posts mostrados
- **AND** default DEBE ser 9
- **AND** DEBE aceptar valores entre 1 y 50
#### Scenario: Atributo columns
- **WHEN** se usa `[roi_post_grid columns="4"]`
- **THEN** DEBE definir columnas en desktop
- **AND** default DEBE ser 3
- **AND** DEBE aceptar valores 1, 2, 3 o 4
#### Scenario: Atributo orderby
- **WHEN** se usa `[roi_post_grid orderby="title"]`
- **THEN** DEBE ordenar posts por ese campo
- **AND** default DEBE ser "date"
- **AND** opciones validas: date, title, modified, rand, comment_count
#### Scenario: Atributo order
- **WHEN** se usa `[roi_post_grid order="ASC"]`
- **THEN** DEBE definir direccion del orden
- **AND** default DEBE ser "DESC"
- **AND** opciones validas: ASC, DESC
#### Scenario: Atributo show_pagination
- **WHEN** se usa `[roi_post_grid show_pagination="true"]`
- **THEN** DEBE mostrar paginacion si hay mas posts
- **AND** default DEBE ser false
#### Scenario: Atributo offset
- **WHEN** se usa `[roi_post_grid offset="3"]`
- **THEN** DEBE saltar los primeros N posts
- **AND** default DEBE ser 0
#### Scenario: Atributo exclude_posts
- **WHEN** se usa `[roi_post_grid exclude_posts="123,456"]`
- **THEN** DEBE excluir posts por ID
- **AND** DEBE aceptar IDs separados por coma
#### Scenario: Atributos de visualizacion
- **WHEN** se usan atributos de visualizacion
- **THEN** show_thumbnail default true
- **AND** show_excerpt default true
- **AND** show_meta default true
- **AND** show_categories default true
- **AND** excerpt_length default 20
#### Scenario: Atributo class
- **WHEN** se usa `[roi_post_grid class="my-custom-grid"]`
- **THEN** DEBE agregar clase CSS adicional al contenedor
#### Scenario: Atributo id para paginacion multiple
- **WHEN** se usa `[roi_post_grid id="grid-1" show_pagination="true"]`
- **THEN** DEBE usar query var unico `paged_grid-1`
- **AND** permite multiples shortcodes paginados en misma pagina
---
### Requirement: Obtencion de Settings
The shortcode MUST obtain styles from post-grid component settings.
#### Scenario: Lectura de configuracion
- **WHEN** RenderPostGridUseCase se ejecuta
- **THEN** DEBE usar ComponentSettingsRepositoryInterface
- **AND** DEBE obtener settings de componente 'post-grid'
- **AND** DEBE aplicar colores, spacing, visual_effects del componente
#### Scenario: Settings no encontrados
- **WHEN** no existen settings de post-grid en BD
- **THEN** DEBE usar valores default definidos en VisibilityDefaults
- **AND** NO DEBE fallar con error
---
### Requirement: Renderizado HTML
The shortcode MUST generate valid HTML with proper escaping.
#### Scenario: Estructura HTML del grid
- **WHEN** el shortcode renderiza
- **THEN** DEBE generar contenedor con clase `roi-post-grid-shortcode`
- **AND** DEBE generar row con clase Bootstrap `row`
- **AND** cada card DEBE tener columna responsive
#### Scenario: Clases responsive de columnas
- **WHEN** columns es 3
- **THEN** cada card DEBE tener `col-12 col-md-6 col-lg-4`
- **AND** para columns=4: `col-12 col-md-6 col-lg-3`
- **AND** para columns=2: `col-12 col-md-6`
- **AND** para columns=1: `col-12`
#### Scenario: Sin resultados
- **WHEN** el query no encuentra posts
- **THEN** DEBE mostrar mensaje "No se encontraron publicaciones"
- **AND** NO DEBE romper layout
#### Scenario: Escaping obligatorio
- **WHEN** se genera HTML
- **THEN** DEBE usar esc_html() para textos
- **AND** DEBE usar esc_attr() para atributos
- **AND** DEBE usar esc_url() para URLs
- **AND** DEBE usar wp_kses_post() para excerpts
---
### Requirement: CSS via CSSGenerator
The shortcode MUST use CSSGeneratorInterface for styles.
#### Scenario: Generacion de CSS
- **WHEN** PostGridShortcodeRenderer genera CSS
- **THEN** DEBE inyectar CSSGeneratorInterface
- **AND** DEBE usar settings de post-grid desde BD
- **AND** DEBE generar CSS inline en el shortcode
#### Scenario: Selector unico
- **WHEN** se genera CSS
- **THEN** DEBE usar selector `.roi-post-grid-shortcode`
- **AND** si se especifica id, usar `.roi-post-grid-shortcode-{id}`
- **AND** NO DEBE conflictuar con post-grid del template
---
### Requirement: Registro del Shortcode
The shortcode MUST be registered in WordPress.
#### Scenario: Registro en bootstrap
- **WHEN** WordPress carga el tema
- **THEN** functions-addon.php DEBE llamar PostGridShortcodeRegistrar::register()
- **AND** DEBE estar disponible en editor clasico y Gutenberg
#### Scenario: Metodo register estatico
- **WHEN** se llama PostGridShortcodeRegistrar::register()
- **THEN** DEBE ejecutar add_shortcode('roi_post_grid', ...)
- **AND** DEBE usar DIContainer para obtener dependencias
---
## Implementation Order
### Fase 1: Interfaces y DTOs
1. Crear `Shared/Domain/Contracts/PostGridQueryBuilderInterface.php`
2. Crear `Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php`
3. Crear `Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php`
### Fase 2: Use Case
1. Crear `Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php`
2. Implementar orquestacion de query, settings, render
### Fase 3: Infrastructure - Query
1. Crear `Shared/Infrastructure/Query/PostGridQueryBuilder.php`
2. Implementar construccion de WP_Query con todos los filtros
### Fase 4: Infrastructure - Renderer
1. Crear `Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php`
2. Implementar generacion HTML y CSS
### Fase 5: Infrastructure - Registrar
1. Crear `Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php`
2. Implementar registro y sanitizacion de atributos
### Fase 6: Registro y DI
1. Registrar en DIContainer las implementaciones
2. Llamar register() en functions-addon.php
### Fase 7: Testing
1. Probar en pagina estatica
2. Verificar filtros por categoria, tag
3. Verificar paginacion con id unico
---
## File Structure
```
Shared/
├── Domain/
│ └── Contracts/
│ ├── PostGridQueryBuilderInterface.php
│ └── PostGridShortcodeRendererInterface.php
├── Application/
│ └── UseCases/
│ └── RenderPostGrid/
│ ├── RenderPostGridRequest.php
│ └── RenderPostGridUseCase.php
└── Infrastructure/
├── Query/
│ └── PostGridQueryBuilder.php
├── Ui/
│ └── PostGridShortcodeRenderer.php
└── Wordpress/
└── PostGridShortcodeRegistrar.php
```
---
## Examples
### Ejemplo: Grid basico
```
[roi_post_grid]
```
### Ejemplo: Posts de una categoria
```
[roi_post_grid category="precios-unitarios" posts_per_page="6"]
```
### Ejemplo: Multiples shortcodes con paginacion
```
[roi_post_grid id="grid-cursos" category="cursos" show_pagination="true"]
[roi_post_grid id="grid-tutoriales" category="tutoriales" show_pagination="true"]
```
---
## Dependencies
### Existentes (reutilizar)
- `CSSGeneratorInterface` - Para generar CSS
- `ComponentSettingsRepositoryInterface` - Para leer config de post-grid
- `DIContainer` - Para inyeccion de dependencias
- Bootstrap 5 grid system - Para layout responsive
### Nuevas (crear)
- `PostGridQueryBuilderInterface` - Contrato para query builder
- `PostGridShortcodeRendererInterface` - Contrato para renderer
- `RenderPostGridRequest` - DTO de entrada
- `RenderPostGridUseCase` - Orquestador
- `PostGridQueryBuilder` - Implementacion query
- `PostGridShortcodeRenderer` - Implementacion render
- `PostGridShortcodeRegistrar` - Registro WordPress
---
## Testing Checklist
- [ ] Shortcode renderiza sin atributos
- [ ] Filtro por categoria funciona
- [ ] Filtro por multiples categorias funciona
- [ ] Exclusion de categoria funciona
- [ ] Filtro por tag funciona
- [ ] Columnas 1, 2, 3, 4 funcionan
- [ ] Paginacion con id unico funciona
- [ ] Multiples shortcodes paginados funcionan
- [ ] CSS se aplica desde settings de post-grid
- [ ] Mensaje "sin posts" aparece cuando corresponde
- [ ] Escaping correcto en todo el HTML
- [ ] Funciona en editor clasico
- [ ] Funciona en Gutenberg
- [ ] No hay errores PHP
- [ ] Clases tienen menos de 150 lineas

27
test-shortcode.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
/**
* Template Name: Test Post Grid Shortcode
*
* Página de prueba para el shortcode [roi_post_grid]
*/
get_header();
?>
<main class="container py-5">
<h1>Prueba de Shortcode [roi_post_grid]</h1>
<h2>Test 1: Grid básico (9 posts)</h2>
<?php echo do_shortcode('[roi_post_grid]'); ?>
<hr class="my-5">
<h2>Test 2: 6 posts en 2 columnas</h2>
<?php echo do_shortcode('[roi_post_grid posts_per_page="6" columns="2"]'); ?>
<hr class="my-5">
<h2>Test 3: 4 posts en 4 columnas sin meta</h2>
<?php echo do_shortcode('[roi_post_grid posts_per_page="4" columns="4" show_meta="false"]'); ?>
</main>
<?php get_footer(); ?>