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\HeadingPermalink;
13
14use League\CommonMark\Block\Element\Document;
15use League\CommonMark\Block\Element\Heading;
16use League\CommonMark\Event\DocumentParsedEvent;
17use League\CommonMark\Exception\InvalidOptionException;
18use League\CommonMark\Extension\HeadingPermalink\Slug\SlugGeneratorInterface as DeprecatedSlugGeneratorInterface;
19use League\CommonMark\Inline\Element\AbstractStringContainer;
20use League\CommonMark\Node\Node;
21use League\CommonMark\Normalizer\SlugNormalizer;
22use League\CommonMark\Normalizer\TextNormalizerInterface;
23use League\CommonMark\Util\ConfigurationAwareInterface;
24use League\CommonMark\Util\ConfigurationInterface;
25
26/**
27 * Searches the Document for Heading elements and adds HeadingPermalinks to each one
28 */
29final class HeadingPermalinkProcessor implements ConfigurationAwareInterface
30{
31    const INSERT_BEFORE = 'before';
32    const INSERT_AFTER = 'after';
33
34    /** @var TextNormalizerInterface|DeprecatedSlugGeneratorInterface */
35    private $slugNormalizer;
36
37    /** @var ConfigurationInterface */
38    private $config;
39
40    /**
41     * @param TextNormalizerInterface|DeprecatedSlugGeneratorInterface|null $slugNormalizer
42     */
43    public function __construct($slugNormalizer = null)
44    {
45        if ($slugNormalizer instanceof DeprecatedSlugGeneratorInterface) {
46            @trigger_error(sprintf('Passing a %s into the %s constructor is deprecated; use a %s instead', DeprecatedSlugGeneratorInterface::class, self::class, TextNormalizerInterface::class), E_USER_DEPRECATED);
47        }
48
49        $this->slugNormalizer = $slugNormalizer ?? new SlugNormalizer();
50    }
51
52    public function setConfiguration(ConfigurationInterface $configuration)
53    {
54        $this->config = $configuration;
55    }
56
57    public function __invoke(DocumentParsedEvent $e): void
58    {
59        $this->useNormalizerFromConfigurationIfProvided();
60
61        $walker = $e->getDocument()->walker();
62
63        while ($event = $walker->next()) {
64            $node = $event->getNode();
65            if ($node instanceof Heading && $event->isEntering()) {
66                $this->addHeadingLink($node, $e->getDocument());
67            }
68        }
69    }
70
71    private function useNormalizerFromConfigurationIfProvided(): void
72    {
73        $generator = $this->config->get('heading_permalink/slug_normalizer');
74        if ($generator === null) {
75            return;
76        }
77
78        if (!($generator instanceof DeprecatedSlugGeneratorInterface || $generator instanceof TextNormalizerInterface)) {
79            throw new InvalidOptionException('The heading_permalink/slug_normalizer option must be an instance of ' . TextNormalizerInterface::class);
80        }
81
82        $this->slugNormalizer = $generator;
83    }
84
85    private function addHeadingLink(Heading $heading, Document $document): void
86    {
87        $text = $this->getChildText($heading);
88        if ($this->slugNormalizer instanceof DeprecatedSlugGeneratorInterface) {
89            $slug = $this->slugNormalizer->createSlug($text);
90        } else {
91            $slug = $this->slugNormalizer->normalize($text, $heading);
92        }
93
94        $slug = $this->ensureUnique($slug, $document);
95
96        $headingLinkAnchor = new HeadingPermalink($slug);
97
98        switch ($this->config->get('heading_permalink/insert', 'before')) {
99            case self::INSERT_BEFORE:
100                $heading->prependChild($headingLinkAnchor);
101
102                return;
103            case self::INSERT_AFTER:
104                $heading->appendChild($headingLinkAnchor);
105
106                return;
107            default:
108                throw new \RuntimeException("Invalid configuration value for heading_permalink/insert; expected 'before' or 'after'");
109        }
110    }
111
112    /**
113     * @deprecated Not needed in 2.0
114     */
115    private function getChildText(Node $node): string
116    {
117        $text = '';
118
119        $walker = $node->walker();
120        while ($event = $walker->next()) {
121            if ($event->isEntering() && (($child = $event->getNode()) instanceof AbstractStringContainer)) {
122                $text .= $child->getContent();
123            }
124        }
125
126        return $text;
127    }
128
129    private function ensureUnique(string $proposed, Document $document): string
130    {
131        // Quick path, it's a unique ID
132        if (!isset($document->data['heading_ids'][$proposed])) {
133            $document->data['heading_ids'][$proposed] = true;
134
135            return $proposed;
136        }
137
138        $extension = 0;
139        do {
140            ++$extension;
141        } while (isset($document->data['heading_ids']["$proposed-$extension"]));
142
143        $document->data['heading_ids']["$proposed-$extension"] = true;
144
145        return "$proposed-$extension";
146    }
147}
148