Files
roi-theme/src/TableOfContents/Infrastructure/Presentation/Public/TableOfContentsRenderer.php
FrankZamora 90de6df77c Fase-01: Preparación del entorno y estructura inicial
- Verificación de entorno XAMPP (PHP 8.0.30, Composer 2.9.1, WP-CLI 2.12.0)
- Configuración de Composer con PSR-4 para 24 namespaces
- Configuración de PHPUnit con 140 tests preparados
- Configuración de PHPCS con WordPress Coding Standards
- Scripts de backup y rollback con mejoras de seguridad
- Estructura de contextos (admin/, public/, shared/)
- Schemas JSON para 11 componentes del sistema
- Código fuente inicial con arquitectura limpia en src/
- Documentación de procedimientos de emergencia

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 16:34:49 -06:00

266 lines
8.5 KiB
PHP

<?php
declare(strict_types=1);
namespace ROITheme\TableOfContents\Infrastructure\Presentation\Public;
use ROITheme\Component\Domain\Component;
use ROITheme\Component\Domain\RendererInterface;
use DOMDocument;
use DOMXPath;
final class TableOfContentsRenderer implements RendererInterface
{
private int $currentPostId;
public function __construct()
{
$this->currentPostId = get_the_ID() ?: 0;
}
public function render(Component $component): string
{
$data = $component->getData();
if (!$this->isEnabled($data)) {
return '';
}
$autoGenerate = $data['config']['auto_generate'] ?? true;
$tocItems = $autoGenerate
? $this->generateTocFromContent($data)
: $this->getManualItems($data);
if (empty($tocItems)) {
return '';
}
$componentId = $component->getId();
$customStyles = $this->generateCustomStyles($componentId, $data['styles'] ?? []);
$stickyClass = ($data['visibility']['sticky'] ?? true) ? 'sidebar-sticky' : '';
$mobileClass = !($data['visibility']['show_on_mobile'] ?? false) ? 'd-none d-lg-block' : '';
$title = $data['config']['title'] ?? 'Tabla de Contenido';
$maxHeight = $data['config']['max_height'] ?? 'calc(100vh - 400px)';
$offsetTop = $data['config']['offset_top'] ?? 100;
ob_start();
?>
<?php if (!empty($customStyles)): ?>
<style>
<?php echo $customStyles; ?>
</style>
<?php endif; ?>
<div id="<?php echo esc_attr($componentId); ?>"
class="toc-container <?php echo esc_attr($stickyClass . ' ' . $mobileClass . ' ' . ($data['config']['custom_css_class'] ?? '')); ?>"
data-bs-spy="scroll"
data-bs-offset="<?php echo esc_attr($offsetTop); ?>"
style="max-height: <?php echo esc_attr($maxHeight); ?>;">
<h4><?php echo esc_html($title); ?></h4>
<ol class="list-unstyled toc-list">
<?php foreach ($tocItems as $item): ?>
<?php
$text = $item['text'] ?? '';
$anchor = $item['anchor'] ?? '';
$level = $item['level'] ?? 2;
if (empty($text) || empty($anchor)) {
continue;
}
$indentClass = $level > 2 ? 'toc-level-' . $level : '';
?>
<li class="<?php echo esc_attr($indentClass); ?>">
<a href="#<?php echo esc_attr($anchor); ?>"
class="toc-link"
data-level="<?php echo esc_attr($level); ?>">
<?php echo esc_html($text); ?>
</a>
</li>
<?php endforeach; ?>
</ol>
</div>
<?php if ($data['config']['smooth_scroll'] ?? true): ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
const tocLinks = document.querySelectorAll('.toc-link');
tocLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href');
const targetElement = document.querySelector(targetId);
if (targetElement) {
const offsetTop = <?php echo intval($offsetTop); ?>;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - offsetTop;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
});
});
if (typeof bootstrap === 'undefined') {
const sections = document.querySelectorAll('[id]');
const navLinks = document.querySelectorAll('.toc-link');
window.addEventListener('scroll', () => {
let current = '';
sections.forEach(section => {
const sectionTop = section.offsetTop;
if (pageYOffset >= (sectionTop - <?php echo intval($offsetTop) + 50; ?>)) {
current = section.getAttribute('id');
}
});
navLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === '#' + current) {
link.classList.add('active');
}
});
});
}
});
</script>
<?php endif; ?>
<?php
return ob_get_clean();
}
private function isEnabled(array $data): bool
{
return isset($data['visibility']['is_enabled']) &&
$data['visibility']['is_enabled'] === true;
}
private function generateTocFromContent(array $data): array
{
global $post;
if (!$post || empty($post->post_content)) {
return [];
}
$headingLevels = $data['config']['heading_levels'] ?? ['h2', 'h3'];
$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);
$xpathQuery = implode(' | ', array_map(function($level) {
return '//' . $level;
}, $headingLevels));
$headings = $xpath->query($xpathQuery);
if ($headings->length === 0) {
return [];
}
$tocItems = [];
$headingCounter = [];
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');
$anchor = !empty($existingId)
? $existingId
: $this->generateAnchorId($text, $headingCounter);
$tocItems[] = [
'text' => $text,
'anchor' => $anchor,
'level' => $level
];
}
return $tocItems;
}
private function getManualItems(array $data): array
{
return $data['manual_items']['items'] ?? [];
}
private function generateAnchorId(string $text, array &$counter): string
{
$id = strtolower($text);
$id = remove_accents($id);
$id = preg_replace('/[^a-z0-9]+/', '-', $id);
$id = trim($id, '-');
$baseId = $id;
$count = 1;
while (isset($counter[$id])) {
$id = $baseId . '-' . $count;
$count++;
}
$counter[$id] = true;
return $id;
}
private function generateCustomStyles(string $componentId, array $styles): string
{
if (empty($styles)) {
return '';
}
$css = [];
if (isset($styles['background_color'])) {
$css[] = "#$componentId.toc-container { background: {$styles['background_color']}; }";
}
if (isset($styles['border_color'])) {
$css[] = "#$componentId.toc-container { border-color: {$styles['border_color']}; }";
}
if (isset($styles['title_color'])) {
$css[] = "#$componentId.toc-container h4 { color: {$styles['title_color']}; }";
}
if (isset($styles['link_color'])) {
$css[] = "#$componentId .toc-link { color: {$styles['link_color']}; }";
}
if (isset($styles['link_hover_color'])) {
$css[] = "#$componentId .toc-link:hover { color: {$styles['link_hover_color']}; }";
}
if (isset($styles['active_border_color'])) {
$css[] = "#$componentId .toc-link.active { border-left-color: {$styles['active_border_color']}; }";
}
if (isset($styles['active_bg_color'])) {
$css[] = "#$componentId .toc-link.active { background-color: {$styles['active_bg_color']}; }";
}
return implode("\n", $css);
}
public function supports(string $componentType): bool
{
return $componentType === 'table-of-contents';
}
}