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\Extension\CommonMark\Node\Block\Heading; 17use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock; 18use League\CommonMark\Extension\CommonMark\Node\Block\ListData; 19use League\CommonMark\Extension\CommonMark\Node\Block\ListItem; 20use League\CommonMark\Extension\CommonMark\Node\Inline\Link; 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\Node\Block\Document; 28use League\CommonMark\Node\NodeIterator; 29use League\CommonMark\Node\RawMarkupContainerInterface; 30use League\CommonMark\Node\StringContainerHelper; 31use League\Config\Exception\InvalidConfigurationException; 32 33final class TableOfContentsGenerator implements TableOfContentsGeneratorInterface 34{ 35 public const STYLE_BULLET = ListBlock::TYPE_BULLET; 36 public const STYLE_ORDERED = ListBlock::TYPE_ORDERED; 37 38 public const NORMALIZE_DISABLED = 'as-is'; 39 public const NORMALIZE_RELATIVE = 'relative'; 40 public const NORMALIZE_FLAT = 'flat'; 41 42 /** @psalm-readonly */ 43 private string $style; 44 45 /** @psalm-readonly */ 46 private string $normalizationStrategy; 47 48 /** @psalm-readonly */ 49 private int $minHeadingLevel; 50 51 /** @psalm-readonly */ 52 private int $maxHeadingLevel; 53 54 /** @psalm-readonly */ 55 private string $fragmentPrefix; 56 57 public function __construct(string $style, string $normalizationStrategy, int $minHeadingLevel, int $maxHeadingLevel, string $fragmentPrefix) 58 { 59 $this->style = $style; 60 $this->normalizationStrategy = $normalizationStrategy; 61 $this->minHeadingLevel = $minHeadingLevel; 62 $this->maxHeadingLevel = $maxHeadingLevel; 63 $this->fragmentPrefix = $fragmentPrefix; 64 65 if ($fragmentPrefix !== '') { 66 $this->fragmentPrefix .= '-'; 67 } 68 } 69 70 public function generate(Document $document): ?TableOfContents 71 { 72 $toc = $this->createToc($document); 73 74 $normalizer = $this->getNormalizer($toc); 75 76 $firstHeading = null; 77 78 foreach ($this->getHeadingLinks($document) as $headingLink) { 79 $heading = $headingLink->parent(); 80 // Make sure this is actually tied to a heading 81 if (! $heading instanceof Heading) { 82 continue; 83 } 84 85 // Skip any headings outside the configured min/max levels 86 if ($heading->getLevel() < $this->minHeadingLevel || $heading->getLevel() > $this->maxHeadingLevel) { 87 continue; 88 } 89 90 // Keep track of the first heading we see - we might need this later 91 $firstHeading ??= $heading; 92 93 // Keep track of the start and end lines 94 $toc->setStartLine($firstHeading->getStartLine()); 95 $toc->setEndLine($heading->getEndLine()); 96 97 // Create the new link 98 $link = new Link('#' . $this->fragmentPrefix . $headingLink->getSlug(), StringContainerHelper::getChildText($heading, [RawMarkupContainerInterface::class])); 99 100 $listItem = new ListItem($toc->getListData()); 101 $listItem->setStartLine($heading->getStartLine()); 102 $listItem->setEndLine($heading->getEndLine()); 103 $listItem->appendChild($link); 104 105 // Add it to the correct place 106 $normalizer->addItem($heading->getLevel(), $listItem); 107 } 108 109 // Don't add the TOC if no headings were present 110 if (! $toc->hasChildren() || $firstHeading === null) { 111 return null; 112 } 113 114 return $toc; 115 } 116 117 private function createToc(Document $document): TableOfContents 118 { 119 $listData = new ListData(); 120 121 if ($this->style === self::STYLE_BULLET) { 122 $listData->type = ListBlock::TYPE_BULLET; 123 } elseif ($this->style === self::STYLE_ORDERED) { 124 $listData->type = ListBlock::TYPE_ORDERED; 125 } else { 126 throw new InvalidConfigurationException(\sprintf('Invalid table of contents list style: "%s"', $this->style)); 127 } 128 129 $toc = new TableOfContents($listData); 130 131 $toc->setStartLine($document->getStartLine()); 132 $toc->setEndLine($document->getEndLine()); 133 134 return $toc; 135 } 136 137 /** 138 * @return iterable<HeadingPermalink> 139 */ 140 private function getHeadingLinks(Document $document): iterable 141 { 142 foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { 143 if (! $node instanceof Heading) { 144 continue; 145 } 146 147 foreach ($node->children() as $child) { 148 if ($child instanceof HeadingPermalink) { 149 yield $child; 150 } 151 } 152 } 153 } 154 155 private function getNormalizer(TableOfContents $toc): NormalizerStrategyInterface 156 { 157 switch ($this->normalizationStrategy) { 158 case self::NORMALIZE_DISABLED: 159 return new AsIsNormalizerStrategy($toc); 160 case self::NORMALIZE_RELATIVE: 161 return new RelativeNormalizerStrategy($toc); 162 case self::NORMALIZE_FLAT: 163 return new FlatNormalizerStrategy($toc); 164 default: 165 throw new InvalidConfigurationException(\sprintf('Invalid table of contents normalization strategy: "%s"', $this->normalizationStrategy)); 166 } 167 } 168} 169