1<?php 2 3/* 4 * This file is part of the league/commonmark package. 5 * 6 * (c) Colin O'Dell <colinodell@gmail.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace League\CommonMark\Extension\TableOfContents; 13 14use League\CommonMark\Block\Element\Document; 15use League\CommonMark\Block\Element\Heading; 16use League\CommonMark\Block\Element\ListBlock; 17use League\CommonMark\Block\Element\ListData; 18use League\CommonMark\Block\Element\ListItem; 19use League\CommonMark\Block\Element\Paragraph; 20use League\CommonMark\Exception\InvalidOptionException; 21use League\CommonMark\Extension\HeadingPermalink\HeadingPermalink; 22use League\CommonMark\Extension\TableOfContents\Node\TableOfContents; 23use League\CommonMark\Extension\TableOfContents\Normalizer\AsIsNormalizerStrategy; 24use League\CommonMark\Extension\TableOfContents\Normalizer\FlatNormalizerStrategy; 25use League\CommonMark\Extension\TableOfContents\Normalizer\NormalizerStrategyInterface; 26use League\CommonMark\Extension\TableOfContents\Normalizer\RelativeNormalizerStrategy; 27use League\CommonMark\Inline\Element\AbstractStringContainer; 28use League\CommonMark\Inline\Element\Link; 29 30final class TableOfContentsGenerator implements TableOfContentsGeneratorInterface 31{ 32 public const STYLE_BULLET = ListBlock::TYPE_BULLET; 33 public const STYLE_ORDERED = ListBlock::TYPE_ORDERED; 34 35 public const NORMALIZE_DISABLED = 'as-is'; 36 public const NORMALIZE_RELATIVE = 'relative'; 37 public const NORMALIZE_FLAT = 'flat'; 38 39 /** @var string */ 40 private $style; 41 /** @var string */ 42 private $normalizationStrategy; 43 /** @var int */ 44 private $minHeadingLevel; 45 /** @var int */ 46 private $maxHeadingLevel; 47 48 public function __construct(string $style, string $normalizationStrategy, int $minHeadingLevel, int $maxHeadingLevel) 49 { 50 $this->style = $style; 51 $this->normalizationStrategy = $normalizationStrategy; 52 $this->minHeadingLevel = $minHeadingLevel; 53 $this->maxHeadingLevel = $maxHeadingLevel; 54 } 55 56 public function generate(Document $document): ?TableOfContents 57 { 58 $toc = $this->createToc($document); 59 60 $normalizer = $this->getNormalizer($toc); 61 62 $firstHeading = null; 63 64 foreach ($this->getHeadingLinks($document) as $headingLink) { 65 $heading = $headingLink->parent(); 66 // Make sure this is actually tied to a heading 67 if (!$heading instanceof Heading) { 68 continue; 69 } 70 71 // Skip any headings outside the configured min/max levels 72 if ($heading->getLevel() < $this->minHeadingLevel || $heading->getLevel() > $this->maxHeadingLevel) { 73 continue; 74 } 75 76 // Keep track of the first heading we see - we might need this later 77 $firstHeading = $firstHeading ?? $heading; 78 79 // Keep track of the start and end lines 80 $toc->setStartLine($firstHeading->getStartLine()); 81 $toc->setEndLine($heading->getEndLine()); 82 83 // Create the new link 84 $link = new Link('#' . $headingLink->getSlug(), self::getHeadingText($heading)); 85 $paragraph = new Paragraph(); 86 $paragraph->setStartLine($heading->getStartLine()); 87 $paragraph->setEndLine($heading->getEndLine()); 88 $paragraph->appendChild($link); 89 90 $listItem = new ListItem($toc->getListData()); 91 $listItem->setStartLine($heading->getStartLine()); 92 $listItem->setEndLine($heading->getEndLine()); 93 $listItem->appendChild($paragraph); 94 95 // Add it to the correct place 96 $normalizer->addItem($heading->getLevel(), $listItem); 97 } 98 99 // Don't add the TOC if no headings were present 100 if (!$toc->hasChildren() || $firstHeading === null) { 101 return null; 102 } 103 104 return $toc; 105 } 106 107 private function createToc(Document $document): TableOfContents 108 { 109 $listData = new ListData(); 110 111 if ($this->style === self::STYLE_BULLET) { 112 $listData->type = ListBlock::TYPE_BULLET; 113 } elseif ($this->style === self::STYLE_ORDERED) { 114 $listData->type = ListBlock::TYPE_ORDERED; 115 } else { 116 throw new InvalidOptionException(\sprintf('Invalid table of contents list style "%s"', $this->style)); 117 } 118 119 $toc = new TableOfContents($listData); 120 121 $toc->setStartLine($document->getStartLine()); 122 $toc->setEndLine($document->getEndLine()); 123 124 return $toc; 125 } 126 127 /** 128 * @param Document $document 129 * 130 * @return iterable<HeadingPermalink> 131 */ 132 private function getHeadingLinks(Document $document) 133 { 134 $walker = $document->walker(); 135 while ($event = $walker->next()) { 136 if ($event->isEntering() && ($node = $event->getNode()) instanceof HeadingPermalink) { 137 yield $node; 138 } 139 } 140 } 141 142 private function getNormalizer(TableOfContents $toc): NormalizerStrategyInterface 143 { 144 switch ($this->normalizationStrategy) { 145 case self::NORMALIZE_DISABLED: 146 return new AsIsNormalizerStrategy($toc); 147 case self::NORMALIZE_RELATIVE: 148 return new RelativeNormalizerStrategy($toc); 149 case self::NORMALIZE_FLAT: 150 return new FlatNormalizerStrategy($toc); 151 default: 152 throw new InvalidOptionException(\sprintf('Invalid table of contents normalization strategy "%s"', $this->normalizationStrategy)); 153 } 154 } 155 156 /** 157 * @return string 158 */ 159 private static function getHeadingText(Heading $heading) 160 { 161 $text = ''; 162 163 $walker = $heading->walker(); 164 while ($event = $walker->next()) { 165 if ($event->isEntering() && ($child = $event->getNode()) instanceof AbstractStringContainer) { 166 $text .= $child->getContent(); 167 } 168 } 169 170 return $text; 171 } 172} 173