1<?php 2 3declare(strict_types=1); 4 5/* 6 * This file is part of the league/commonmark package. 7 * 8 * (c) Colin O'Dell <colinodell@gmail.com> 9 * 10 * For the full copyright and license information, please view the LICENSE 11 * file that was distributed with this source code. 12 */ 13 14namespace League\CommonMark\Extension\TableOfContents; 15 16use League\CommonMark\Event\DocumentParsedEvent; 17use League\CommonMark\Extension\CommonMark\Node\Block\Heading; 18use League\CommonMark\Extension\HeadingPermalink\HeadingPermalink; 19use League\CommonMark\Extension\TableOfContents\Node\TableOfContents; 20use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder; 21use League\CommonMark\Node\Block\Document; 22use League\CommonMark\Node\NodeIterator; 23use League\Config\ConfigurationAwareInterface; 24use League\Config\ConfigurationInterface; 25use League\Config\Exception\InvalidConfigurationException; 26 27final class TableOfContentsBuilder implements ConfigurationAwareInterface 28{ 29 public const POSITION_TOP = 'top'; 30 public const POSITION_BEFORE_HEADINGS = 'before-headings'; 31 public const POSITION_PLACEHOLDER = 'placeholder'; 32 33 /** @psalm-readonly-allow-private-mutation */ 34 private ConfigurationInterface $config; 35 36 public function onDocumentParsed(DocumentParsedEvent $event): void 37 { 38 $document = $event->getDocument(); 39 40 $generator = new TableOfContentsGenerator( 41 (string) $this->config->get('table_of_contents/style'), 42 (string) $this->config->get('table_of_contents/normalize'), 43 (int) $this->config->get('table_of_contents/min_heading_level'), 44 (int) $this->config->get('table_of_contents/max_heading_level'), 45 (string) $this->config->get('heading_permalink/fragment_prefix'), 46 ); 47 48 $toc = $generator->generate($document); 49 if ($toc === null) { 50 // No linkable headers exist, so no TOC could be generated 51 return; 52 } 53 54 // Add custom CSS class(es), if defined 55 $class = $this->config->get('table_of_contents/html_class'); 56 if ($class !== null) { 57 $toc->data->append('attributes/class', $class); 58 } 59 60 // Add the TOC to the Document 61 $position = $this->config->get('table_of_contents/position'); 62 if ($position === self::POSITION_TOP) { 63 $document->prependChild($toc); 64 } elseif ($position === self::POSITION_BEFORE_HEADINGS) { 65 $this->insertBeforeFirstLinkedHeading($document, $toc); 66 } elseif ($position === self::POSITION_PLACEHOLDER) { 67 $this->replacePlaceholders($document, $toc); 68 } else { 69 throw InvalidConfigurationException::forConfigOption('table_of_contents/position', $position); 70 } 71 } 72 73 private function insertBeforeFirstLinkedHeading(Document $document, TableOfContents $toc): void 74 { 75 foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { 76 if (! $node instanceof Heading) { 77 continue; 78 } 79 80 foreach ($node->children() as $child) { 81 if ($child instanceof HeadingPermalink) { 82 $node->insertBefore($toc); 83 84 return; 85 } 86 } 87 } 88 } 89 90 private function replacePlaceholders(Document $document, TableOfContents $toc): void 91 { 92 foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { 93 // Add the block once we find a placeholder 94 if (! $node instanceof TableOfContentsPlaceholder) { 95 continue; 96 } 97 98 $node->replaceWith(clone $toc); 99 } 100 } 101 102 public function setConfiguration(ConfigurationInterface $configuration): void 103 { 104 $this->config = $configuration; 105 } 106} 107