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