Files
roi-theme/Public/TableOfContents/Infrastructure/Ui/TableOfContentsRenderer.php
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

621 lines
22 KiB
PHP

<?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 ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
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 const COMPONENT_NAME = 'table-of-contents';
private array $headingCounter = [];
public function __construct(
private CSSGeneratorInterface $cssGenerator
) {}
public function render(Component $component): string
{
$data = $component->getData();
// DEBUG TOC: Log all visibility checks
$isLoggedIn = is_user_logged_in();
$debugPrefix = "TOC DEBUG [" . ($isLoggedIn ? "LOGGED" : "GUEST") . "]";
if (!$this->isEnabled($data)) {
error_log("{$debugPrefix}: SKIP - isEnabled=false");
return '';
}
$shouldShow = PageVisibilityHelper::shouldShow(self::COMPONENT_NAME);
if (!$shouldShow) {
error_log("{$debugPrefix}: SKIP - PageVisibilityHelper::shouldShow=false");
return '';
}
$tocItems = $this->generateTocItems($data);
if (empty($tocItems)) {
error_log("{$debugPrefix}: SKIP - tocItems empty");
return '';
}
error_log("{$debugPrefix}: RENDER - passed all checks, items=" . count($tocItems));
$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 === self::COMPONENT_NAME;
}
private function isEnabled(array $data): bool
{
return ($data['visibility']['is_enabled'] ?? false) === 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;
// DEBUG: Track content processing
$isLoggedIn = is_user_logged_in();
$debugPrefix = "TOC generateTocFromContent [" . ($isLoggedIn ? "LOGGED" : "GUEST") . "]";
if (!$post || empty($post->post_content)) {
error_log("{$debugPrefix}: SKIP - no post or empty content");
return [];
}
error_log("{$debugPrefix}: post_id={$post->ID}, raw_content_length=" . strlen($post->post_content));
// Check if raw content has headings
$rawHeadingCount = preg_match_all('/<h[2-6][^>]*>/i', $post->post_content, $rawMatches);
error_log("{$debugPrefix}: raw_headings_count={$rawHeadingCount}");
$content = apply_filters('the_content', $post->post_content);
error_log("{$debugPrefix}: filtered_content_length=" . strlen($content));
// Check if filtered content has headings
$filteredHeadingCount = preg_match_all('/<h[2-6][^>]*>/i', $content, $filteredMatches);
error_log("{$debugPrefix}: filtered_headings_count={$filteredHeadingCount}");
// Log first 500 chars of filtered content for diagnosis
error_log("{$debugPrefix}: filtered_content_preview=" . substr(strip_tags($content), 0, 300));
$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);
error_log("{$debugPrefix}: headings_found=" . $headings->length);
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');
// Generar anchor ID - la inyección de IDs se hace via JavaScript
// para evitar problemas con orden de filtros WordPress y caché
if (empty($existingId)) {
$anchor = $this->generateAnchorId($text);
} 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);
}
public 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',
]);
// Focus - eliminar outline azul del browser en click
$cssRules[] = $this->cssGenerator->generate('.toc-container .toc-link:focus', [
'outline' => 'none',
]);
// 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(
'<span class="toc-title d-block h4">%s</span>',
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 con inyección de IDs client-side para mayor fiabilidad
// Resuelve problemas de orden de filtros WordPress y caché
$script = <<<JS
<script>
document.addEventListener('DOMContentLoaded', function() {
var tocLinks = document.querySelectorAll('.toc-link');
var offsetTop = {$scrollOffset};
// ===========================================
// PASO 1: Generar IDs para headings sin ID
// ===========================================
function generateSlug(text) {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Remover acentos
.replace(/[^a-z0-9]+/g, '-') // Reemplazar caracteres especiales
.replace(/^-+|-+$/g, ''); // Trim guiones
}
// Mapeo de IDs existentes para evitar duplicados
var existingIds = {};
document.querySelectorAll('[id]').forEach(function(el) {
existingIds[el.id] = true;
});
// Para cada link del TOC, buscar el heading correspondiente y asignarle ID
tocLinks.forEach(function(link) {
var targetId = link.getAttribute('href').substring(1); // Remover #
var existingElement = document.getElementById(targetId);
// Si ya existe el elemento con ese ID, no hacer nada
if (existingElement) return;
// Buscar el heading por su texto
var linkText = link.textContent.trim();
var headings = document.querySelectorAll('h2, h3, h4, h5, h6');
headings.forEach(function(heading) {
// Comparar texto normalizado
var headingText = heading.textContent.trim();
if (headingText === linkText && !heading.id) {
// Asignar el ID que espera el TOC
heading.id = targetId;
existingIds[targetId] = true;
}
});
});
// ===========================================
// PASO 2: Smooth scroll on click
// ===========================================
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'
});
}
});
});
// ===========================================
// PASO 3: ScrollSpy con Intersection Observer
// ===========================================
var sections = [];
var sectionMap = {};
tocLinks.forEach(function(link) {
var id = link.getAttribute('href').substring(1);
var section = document.getElementById(id);
if (section) {
sections.push(section);
sectionMap[id] = link;
}
});
if (sections.length === 0) return;
var visibleSections = new Set();
var currentActive = null;
function updateActiveFromVisible() {
if (visibleSections.size === 0) return;
var topMostSection = null;
var topMostPosition = Infinity;
visibleSections.forEach(function(id) {
var section = document.getElementById(id);
if (section) {
var rect = section.getBoundingClientRect();
if (rect.top < topMostPosition) {
topMostPosition = rect.top;
topMostSection = id;
}
}
});
if (topMostSection && topMostSection !== currentActive) {
tocLinks.forEach(function(link) {
link.classList.remove('active');
});
if (sectionMap[topMostSection]) {
sectionMap[topMostSection].classList.add('active');
currentActive = topMostSection;
// Auto-scroll del TOC para mostrar el elemento activo
sectionMap[topMostSection].scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
}
}
var observerOptions = {
root: null,
rootMargin: '-' + offsetTop + 'px 0px -50% 0px',
threshold: 0
};
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
var id = entry.target.id;
if (entry.isIntersecting) {
visibleSections.add(id);
} else {
visibleSections.delete(id);
}
});
requestAnimationFrame(updateActiveFromVisible);
}, observerOptions);
sections.forEach(function(section) {
observer.observe(section);
});
// Activar primera sección al cargar
requestAnimationFrame(function() {
if (visibleSections.size === 0 && sections.length > 0) {
var firstId = sections[0].id;
if (sectionMap[firstId]) {
sectionMap[firstId].classList.add('active');
currentActive = firstId;
}
}
});
});
</script>
JS;
return $script;
}
}