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