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 * Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js) 9 * - (c) John MacFarlane 10 * 11 * For the full copyright and license information, please view the LICENSE 12 * file that was distributed with this source code. 13 */ 14 15namespace League\CommonMark\Inline\Parser; 16 17use League\CommonMark\Cursor; 18use League\CommonMark\Delimiter\DelimiterInterface; 19use League\CommonMark\EnvironmentAwareInterface; 20use League\CommonMark\EnvironmentInterface; 21use League\CommonMark\Extension\Mention\Mention; 22use League\CommonMark\Inline\AdjacentTextMerger; 23use League\CommonMark\Inline\Element\AbstractWebResource; 24use League\CommonMark\Inline\Element\Image; 25use League\CommonMark\Inline\Element\Link; 26use League\CommonMark\Inline\Element\Text; 27use League\CommonMark\InlineParserContext; 28use League\CommonMark\Reference\ReferenceInterface; 29use League\CommonMark\Reference\ReferenceMapInterface; 30use League\CommonMark\Util\LinkParserHelper; 31use League\CommonMark\Util\RegexHelper; 32 33final class CloseBracketParser implements InlineParserInterface, EnvironmentAwareInterface 34{ 35 /** 36 * @var EnvironmentInterface 37 */ 38 private $environment; 39 40 public function getCharacters(): array 41 { 42 return [']']; 43 } 44 45 public function parse(InlineParserContext $inlineContext): bool 46 { 47 // Look through stack of delimiters for a [ or ! 48 $opener = $inlineContext->getDelimiterStack()->searchByCharacter(['[', '!']); 49 if ($opener === null) { 50 return false; 51 } 52 53 if (!$opener->isActive()) { 54 // no matched opener; remove from emphasis stack 55 $inlineContext->getDelimiterStack()->removeDelimiter($opener); 56 57 return false; 58 } 59 60 $cursor = $inlineContext->getCursor(); 61 62 $startPos = $cursor->getPosition(); 63 $previousState = $cursor->saveState(); 64 65 $cursor->advanceBy(1); 66 67 // Check to see if we have a link/image 68 if (!($link = $this->tryParseLink($cursor, $inlineContext->getReferenceMap(), $opener, $startPos))) { 69 // No match 70 $inlineContext->getDelimiterStack()->removeDelimiter($opener); // Remove this opener from stack 71 $cursor->restoreState($previousState); 72 73 return false; 74 } 75 76 $isImage = $opener->getChar() === '!'; 77 78 $inline = $this->createInline($link['url'], $link['title'], $isImage); 79 $opener->getInlineNode()->replaceWith($inline); 80 while (($label = $inline->next()) !== null) { 81 // Is there a Mention contained within this link? 82 // CommonMark does not allow nested links, so we'll restore the original text. 83 if ($label instanceof Mention) { 84 $label->replaceWith($replacement = new Text($label->getSymbol() . $label->getIdentifier())); 85 $label = $replacement; 86 } 87 88 $inline->appendChild($label); 89 } 90 91 // Process delimiters such as emphasis inside link/image 92 $delimiterStack = $inlineContext->getDelimiterStack(); 93 $stackBottom = $opener->getPrevious(); 94 $delimiterStack->processDelimiters($stackBottom, $this->environment->getDelimiterProcessors()); 95 $delimiterStack->removeAll($stackBottom); 96 97 // Merge any adjacent Text nodes together 98 AdjacentTextMerger::mergeChildNodes($inline); 99 100 // processEmphasis will remove this and later delimiters. 101 // Now, for a link, we also remove earlier link openers (no links in links) 102 if (!$isImage) { 103 $inlineContext->getDelimiterStack()->removeEarlierMatches('['); 104 } 105 106 return true; 107 } 108 109 public function setEnvironment(EnvironmentInterface $environment) 110 { 111 $this->environment = $environment; 112 } 113 114 /** 115 * @param Cursor $cursor 116 * @param ReferenceMapInterface $referenceMap 117 * @param DelimiterInterface $opener 118 * @param int $startPos 119 * 120 * @return array<string, string>|false 121 */ 122 private function tryParseLink(Cursor $cursor, ReferenceMapInterface $referenceMap, DelimiterInterface $opener, int $startPos) 123 { 124 // Check to see if we have a link/image 125 // Inline link? 126 if ($result = $this->tryParseInlineLinkAndTitle($cursor)) { 127 return $result; 128 } 129 130 if ($link = $this->tryParseReference($cursor, $referenceMap, $opener, $startPos)) { 131 return ['url' => $link->getDestination(), 'title' => $link->getTitle()]; 132 } 133 134 return false; 135 } 136 137 /** 138 * @param Cursor $cursor 139 * 140 * @return array<string, string>|false 141 */ 142 private function tryParseInlineLinkAndTitle(Cursor $cursor) 143 { 144 if ($cursor->getCharacter() !== '(') { 145 return false; 146 } 147 148 $previousState = $cursor->saveState(); 149 150 $cursor->advanceBy(1); 151 $cursor->advanceToNextNonSpaceOrNewline(); 152 if (($dest = LinkParserHelper::parseLinkDestination($cursor)) === null) { 153 $cursor->restoreState($previousState); 154 155 return false; 156 } 157 158 $cursor->advanceToNextNonSpaceOrNewline(); 159 160 $title = ''; 161 // make sure there's a space before the title: 162 if (\preg_match(RegexHelper::REGEX_WHITESPACE_CHAR, $cursor->peek(-1))) { 163 $title = LinkParserHelper::parseLinkTitle($cursor) ?? ''; 164 } 165 166 $cursor->advanceToNextNonSpaceOrNewline(); 167 168 if ($cursor->getCharacter() !== ')') { 169 $cursor->restoreState($previousState); 170 171 return false; 172 } 173 174 $cursor->advanceBy(1); 175 176 return ['url' => $dest, 'title' => $title]; 177 } 178 179 private function tryParseReference(Cursor $cursor, ReferenceMapInterface $referenceMap, DelimiterInterface $opener, int $startPos): ?ReferenceInterface 180 { 181 if ($opener->getIndex() === null) { 182 return null; 183 } 184 185 $savePos = $cursor->saveState(); 186 $beforeLabel = $cursor->getPosition(); 187 $n = LinkParserHelper::parseLinkLabel($cursor); 188 if ($n === 0 || $n === 2) { 189 $start = $opener->getIndex(); 190 $length = $startPos - $opener->getIndex(); 191 } else { 192 $start = $beforeLabel + 1; 193 $length = $n - 2; 194 } 195 196 $referenceLabel = $cursor->getSubstring($start, $length); 197 198 if ($n === 0) { 199 // If shortcut reference link, rewind before spaces we skipped 200 $cursor->restoreState($savePos); 201 } 202 203 return $referenceMap->getReference($referenceLabel); 204 } 205 206 private function createInline(string $url, string $title, bool $isImage): AbstractWebResource 207 { 208 if ($isImage) { 209 return new Image($url, null, $title); 210 } 211 212 return new Link($url, null, $title); 213 } 214} 215