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