- 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>
266 lines
8.5 KiB
PHP
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';
|
|
}
|
|
}
|