Migración completa a Clean Architecture con componentes funcionales
- Reorganización de estructura: Admin/, Public/, Shared/, Schemas/ - 12 componentes migrados: TopNotificationBar, Navbar, CtaLetsTalk, Hero, FeaturedImage, TableOfContents, CtaBoxSidebar, SocialShare, CtaPost, RelatedPost, ContactForm, Footer - Panel de administración con tabs Bootstrap 5 funcionales - Schemas JSON para configuración de componentes - Renderers dinámicos con CSSGeneratorService (cero CSS hardcodeado) - FormBuilders para UI admin con Design System consistente - Fix: Bootstrap JS cargado en header para tabs funcionales - Fix: buildTextInput maneja valores mixed (bool/string) - Eliminación de estructura legacy (src/, admin/, assets/css/componente-*) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Public\TableOfContents\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\RendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
|
||||
use ROITheme\Shared\Domain\Entities\Component;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
|
||||
/**
|
||||
* TableOfContentsRenderer - Renderiza tabla de contenido con navegacion automatica
|
||||
*
|
||||
* RESPONSABILIDAD: Generar HTML y CSS de la tabla de contenido
|
||||
*
|
||||
* CARACTERISTICAS:
|
||||
* - Generacion automatica desde headings del contenido
|
||||
* - ScrollSpy para navegacion activa
|
||||
* - Sticky positioning configurable
|
||||
* - Smooth scroll
|
||||
* - Estilos 100% desde BD via CSSGenerator
|
||||
*
|
||||
* Cumple con:
|
||||
* - DIP: Recibe CSSGeneratorInterface por constructor
|
||||
* - SRP: Una responsabilidad (renderizar TOC)
|
||||
* - Clean Architecture: Infrastructure puede usar WordPress
|
||||
*
|
||||
* @package ROITheme\Public\TableOfContents\Infrastructure\Ui
|
||||
*/
|
||||
final class TableOfContentsRenderer implements RendererInterface
|
||||
{
|
||||
private array $headingCounter = [];
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
|
||||
public function render(Component $component): string
|
||||
{
|
||||
$data = $component->getData();
|
||||
|
||||
if (!$this->isEnabled($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$tocItems = $this->generateTocItems($data);
|
||||
|
||||
if (empty($tocItems)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$css = $this->generateCSS($data);
|
||||
$html = $this->buildHTML($data, $tocItems);
|
||||
$script = $this->buildScript($data);
|
||||
|
||||
return sprintf("<style>%s</style>\n%s\n%s", $css, $html, $script);
|
||||
}
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'table-of-contents';
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
{
|
||||
return ($data['visibility']['is_enabled'] ?? false) === true;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'posts':
|
||||
return is_single();
|
||||
case 'pages':
|
||||
return is_page();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
|
||||
{
|
||||
if (!$desktop && !$mobile) {
|
||||
return null;
|
||||
}
|
||||
if (!$desktop && $mobile) {
|
||||
return 'd-lg-none';
|
||||
}
|
||||
if ($desktop && !$mobile) {
|
||||
return 'd-none d-lg-block';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function generateTocItems(array $data): array
|
||||
{
|
||||
$content = $data['content'] ?? [];
|
||||
$autoGenerate = $content['auto_generate'] ?? true;
|
||||
|
||||
if (!$autoGenerate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$headingLevelsStr = $content['heading_levels'] ?? 'h2,h3';
|
||||
$headingLevels = array_map('trim', explode(',', $headingLevelsStr));
|
||||
|
||||
return $this->generateTocFromContent($headingLevels);
|
||||
}
|
||||
|
||||
private function generateTocFromContent(array $headingLevels): array
|
||||
{
|
||||
global $post;
|
||||
|
||||
if (!$post || empty($post->post_content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = apply_filters('the_content', $post->post_content);
|
||||
|
||||
$dom = new DOMDocument();
|
||||
libxml_use_internal_errors(true);
|
||||
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $content);
|
||||
libxml_clear_errors();
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
$tocItems = [];
|
||||
|
||||
$xpathQuery = implode(' | ', array_map(function($level) {
|
||||
return '//' . $level;
|
||||
}, $headingLevels));
|
||||
|
||||
$headings = $xpath->query($xpathQuery);
|
||||
|
||||
if ($headings->length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($headings as $heading) {
|
||||
$tagName = strtolower($heading->tagName);
|
||||
$level = intval(substr($tagName, 1));
|
||||
|
||||
$text = trim($heading->textContent);
|
||||
|
||||
if (empty($text)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingId = $heading->getAttribute('id');
|
||||
|
||||
if (empty($existingId)) {
|
||||
$anchor = $this->generateAnchorId($text);
|
||||
$this->addIdToHeading($text, $anchor);
|
||||
} else {
|
||||
$anchor = $existingId;
|
||||
}
|
||||
|
||||
$tocItems[] = [
|
||||
'text' => $text,
|
||||
'anchor' => $anchor,
|
||||
'level' => $level
|
||||
];
|
||||
}
|
||||
|
||||
return $tocItems;
|
||||
}
|
||||
|
||||
private function generateAnchorId(string $text): string
|
||||
{
|
||||
$id = strtolower($text);
|
||||
$id = remove_accents($id);
|
||||
$id = preg_replace('/[^a-z0-9]+/', '-', $id);
|
||||
$id = trim($id, '-');
|
||||
|
||||
$baseId = $id;
|
||||
$count = 1;
|
||||
|
||||
while (isset($this->headingCounter[$id])) {
|
||||
$id = $baseId . '-' . $count;
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->headingCounter[$id] = true;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function addIdToHeading(string $headingText, string $anchorId): void
|
||||
{
|
||||
add_filter('the_content', function($content) use ($headingText, $anchorId) {
|
||||
$pattern = '/<(h[2-6])([^>]*)>(\s*)' . preg_quote($headingText, '/') . '(\s*)<\/\1>/i';
|
||||
$replacement = '<$1 id="' . esc_attr($anchorId) . '"$2>$3' . $headingText . '$4</$1>';
|
||||
return preg_replace($pattern, $replacement, $content, 1);
|
||||
}, 20);
|
||||
}
|
||||
|
||||
private function generateCSS(array $data): string
|
||||
{
|
||||
$colors = $data['colors'] ?? [];
|
||||
$spacing = $data['spacing'] ?? [];
|
||||
$typography = $data['typography'] ?? [];
|
||||
$effects = $data['visual_effects'] ?? [];
|
||||
$behavior = $data['behavior'] ?? [];
|
||||
$visibility = $data['visibility'] ?? [];
|
||||
|
||||
$cssRules = [];
|
||||
|
||||
// Container styles - Flexbox layout for proper scrolling
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container', [
|
||||
'background-color' => $colors['background_color'] ?? '#ffffff',
|
||||
'border' => ($effects['border_width'] ?? '1px') . ' solid ' . ($colors['border_color'] ?? '#E6E9ED'),
|
||||
'border-radius' => $effects['border_radius'] ?? '8px',
|
||||
'box-shadow' => $effects['box_shadow'] ?? '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
'padding' => $spacing['container_padding'] ?? '12px 16px',
|
||||
'margin-bottom' => $spacing['margin_bottom'] ?? '13px',
|
||||
'max-height' => $behavior['max_height'] ?? 'calc(100vh - 71px - 10px - 250px - 15px - 15px)',
|
||||
'display' => 'flex',
|
||||
'flex-direction' => 'column',
|
||||
'overflow' => 'visible',
|
||||
]);
|
||||
|
||||
// Sticky behavior - aplica al wrapper .sidebar-sticky de single.php
|
||||
// NO al .toc-container individual (ver template líneas 817-835)
|
||||
if (($behavior['is_sticky'] ?? true)) {
|
||||
$cssRules[] = $this->cssGenerator->generate('.sidebar-sticky', [
|
||||
'position' => 'sticky',
|
||||
'top' => '85px',
|
||||
'display' => 'flex',
|
||||
'flex-direction' => 'column',
|
||||
]);
|
||||
}
|
||||
|
||||
// Custom scrollbar
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar', [
|
||||
'width' => $spacing['scrollbar_width'] ?? '6px',
|
||||
]);
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar-track', [
|
||||
'background' => $colors['scrollbar_track_color'] ?? '#F9FAFB',
|
||||
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
|
||||
]);
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container::-webkit-scrollbar-thumb', [
|
||||
'background' => $colors['scrollbar_thumb_color'] ?? '#6B7280',
|
||||
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
|
||||
]);
|
||||
|
||||
// Title styles - Color #1e3a5f = navy-primary del Design System
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-title', [
|
||||
'font-size' => $typography['title_font_size'] ?? '1rem',
|
||||
'font-weight' => $typography['title_font_weight'] ?? '600',
|
||||
'color' => $colors['title_color'] ?? '#1e3a5f',
|
||||
'padding-bottom' => $spacing['title_padding_bottom'] ?? '8px',
|
||||
'margin-bottom' => $spacing['title_margin_bottom'] ?? '0.75rem',
|
||||
'border-bottom' => '2px solid ' . ($colors['title_border_color'] ?? '#E6E9ED'),
|
||||
'margin-top' => '0',
|
||||
]);
|
||||
|
||||
// List styles - Scrollable area with flex
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list', [
|
||||
'margin' => '0',
|
||||
'padding' => '0',
|
||||
'padding-right' => '0.5rem',
|
||||
'list-style' => 'none',
|
||||
'overflow-y' => 'auto',
|
||||
'flex' => '1',
|
||||
'min-height' => '0',
|
||||
]);
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list li', [
|
||||
'margin-bottom' => $spacing['item_margin_bottom'] ?? '0.15rem',
|
||||
]);
|
||||
|
||||
// Link styles - Color #495057 = neutral-600 del template
|
||||
$transitionDuration = $effects['transition_duration'] ?? '0.3s';
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link', [
|
||||
'display' => 'block',
|
||||
'font-size' => $typography['link_font_size'] ?? '0.9rem',
|
||||
'line-height' => $typography['link_line_height'] ?? '1.3',
|
||||
'color' => $colors['link_color'] ?? '#495057',
|
||||
'text-decoration' => 'none',
|
||||
'padding' => $spacing['link_padding'] ?? '0.3rem 0.85rem',
|
||||
'border-radius' => $effects['link_border_radius'] ?? '4px',
|
||||
'border-left' => ($effects['active_border_left_width'] ?? '3px') . ' solid transparent',
|
||||
'transition' => "all {$transitionDuration} ease",
|
||||
]);
|
||||
|
||||
// Link hover - Color #1e3a5f = navy-primary del Design System
|
||||
// Template: background, border-left-color, color
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link:hover', [
|
||||
'color' => $colors['link_hover_color'] ?? '#1e3a5f',
|
||||
'background-color' => $colors['link_hover_background'] ?? '#F9FAFB',
|
||||
'border-left-color' => $colors['active_border_color'] ?? '#1e3a5f',
|
||||
]);
|
||||
|
||||
// Active link - Color #1e3a5f = navy-primary del Design System
|
||||
// Template: font-weight: 600
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link.active', [
|
||||
'color' => $colors['active_text_color'] ?? '#1e3a5f',
|
||||
'background-color' => $colors['active_background_color'] ?? '#F9FAFB',
|
||||
'border-left-color' => $colors['active_border_color'] ?? '#1e3a5f',
|
||||
'font-weight' => '600',
|
||||
]);
|
||||
|
||||
// Level indentation
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-level-3 .toc-link', [
|
||||
'padding-left' => $spacing['level_three_padding_left'] ?? '1.5rem',
|
||||
'font-size' => $typography['level_three_font_size'] ?? '0.85rem',
|
||||
]);
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-level-4 .toc-link', [
|
||||
'padding-left' => $spacing['level_four_padding_left'] ?? '2rem',
|
||||
'font-size' => $typography['level_four_font_size'] ?? '0.8rem',
|
||||
]);
|
||||
|
||||
// Scrollbar for toc-list
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar', [
|
||||
'width' => $spacing['scrollbar_width'] ?? '6px',
|
||||
]);
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-track', [
|
||||
'background' => $colors['scrollbar_track_color'] ?? '#F9FAFB',
|
||||
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
|
||||
]);
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-thumb', [
|
||||
'background' => $colors['scrollbar_thumb_color'] ?? '#6B7280',
|
||||
'border-radius' => $effects['scrollbar_border_radius'] ?? '3px',
|
||||
]);
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-list::-webkit-scrollbar-thumb:hover', [
|
||||
'background' => $colors['active_border_color'] ?? '#1e3a5f',
|
||||
]);
|
||||
|
||||
// Responsive visibility
|
||||
$showOnDesktop = $visibility['show_on_desktop'] ?? true;
|
||||
$showOnMobile = $visibility['show_on_mobile'] ?? false;
|
||||
|
||||
if (!$showOnMobile) {
|
||||
$cssRules[] = "@media (max-width: 991.98px) {
|
||||
.toc-container { display: none !important; }
|
||||
}";
|
||||
}
|
||||
|
||||
if (!$showOnDesktop) {
|
||||
$cssRules[] = "@media (min-width: 992px) {
|
||||
.toc-container { display: none !important; }
|
||||
}";
|
||||
}
|
||||
|
||||
// Responsive layout adjustments
|
||||
$cssRules[] = "@media (max-width: 991px) {
|
||||
.sidebar-sticky {
|
||||
position: relative !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
.toc-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.toc-container .toc-list {
|
||||
max-height: 300px;
|
||||
}
|
||||
}";
|
||||
|
||||
return implode("\n", $cssRules);
|
||||
}
|
||||
|
||||
private function buildHTML(array $data, array $tocItems): string
|
||||
{
|
||||
$content = $data['content'] ?? [];
|
||||
|
||||
$title = $content['title'] ?? 'Tabla de Contenido';
|
||||
|
||||
// NOTA: El sticky behavior se maneja en el wrapper .sidebar-sticky de single.php
|
||||
// El TOC no debe tener la clase sidebar-sticky - está dentro del wrapper
|
||||
$html = '<div class="toc-container">';
|
||||
|
||||
$html .= sprintf(
|
||||
'<h4 class="toc-title">%s</h4>',
|
||||
esc_html($title)
|
||||
);
|
||||
|
||||
$html .= '<ol class="list-unstyled toc-list">';
|
||||
|
||||
foreach ($tocItems as $item) {
|
||||
$text = $item['text'] ?? '';
|
||||
$anchor = $item['anchor'] ?? '';
|
||||
$level = $item['level'] ?? 2;
|
||||
|
||||
if (empty($text) || empty($anchor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$indentClass = $level > 2 ? 'toc-level-' . $level : '';
|
||||
|
||||
$html .= sprintf(
|
||||
'<li class="%s"><a href="#%s" class="toc-link" data-level="%d">%s</a></li>',
|
||||
esc_attr($indentClass),
|
||||
esc_attr($anchor),
|
||||
intval($level),
|
||||
esc_html($text)
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</ol>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildScript(array $data): string
|
||||
{
|
||||
$content = $data['content'] ?? [];
|
||||
$behavior = $data['behavior'] ?? [];
|
||||
|
||||
$smoothScroll = $content['smooth_scroll'] ?? true;
|
||||
$scrollOffset = intval($behavior['scroll_offset'] ?? 100);
|
||||
|
||||
if (!$smoothScroll) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$script = <<<JS
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var tocLinks = document.querySelectorAll('.toc-link');
|
||||
var offsetTop = {$scrollOffset};
|
||||
|
||||
tocLinks.forEach(function(link) {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var targetId = this.getAttribute('href');
|
||||
var targetElement = document.querySelector(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
var elementPosition = targetElement.getBoundingClientRect().top;
|
||||
var offsetPosition = elementPosition + window.pageYOffset - offsetTop;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ScrollSpy
|
||||
var sections = [];
|
||||
tocLinks.forEach(function(link) {
|
||||
var id = link.getAttribute('href').substring(1);
|
||||
var section = document.getElementById(id);
|
||||
if (section) {
|
||||
sections.push({ id: id, element: section });
|
||||
}
|
||||
});
|
||||
|
||||
function updateActiveLink() {
|
||||
var scrollPosition = window.pageYOffset + offsetTop + 50;
|
||||
var currentSection = '';
|
||||
|
||||
sections.forEach(function(section) {
|
||||
if (section.element.offsetTop <= scrollPosition) {
|
||||
currentSection = section.id;
|
||||
}
|
||||
});
|
||||
|
||||
tocLinks.forEach(function(link) {
|
||||
link.classList.remove('active');
|
||||
if (link.getAttribute('href') === '#' + currentSection) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', updateActiveLink);
|
||||
updateActiveLink();
|
||||
});
|
||||
</script>
|
||||
JS;
|
||||
|
||||
return $script;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user