mirror of
https://gitlab.com/signalytic/client-external/streamline/streamline-emr.git
synced 2026-09-12 03:01:32 +00:00
resolved conflicts
This commit is contained in:
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Delimiter;
|
||||
|
||||
use League\CommonMark\Node\Inline\AbstractStringContainer;
|
||||
|
||||
interface DelimiterInterface
|
||||
{
|
||||
public function canClose(): bool;
|
||||
|
||||
public function canOpen(): bool;
|
||||
|
||||
/**
|
||||
* @deprecated This method is no longer used internally and will be removed in 3.0
|
||||
*/
|
||||
public function isActive(): bool;
|
||||
|
||||
/**
|
||||
* @deprecated This method is no longer used internally and will be removed in 3.0
|
||||
*/
|
||||
public function setActive(bool $active): void;
|
||||
|
||||
public function getChar(): string;
|
||||
|
||||
public function getIndex(): ?int;
|
||||
|
||||
public function getNext(): ?DelimiterInterface;
|
||||
|
||||
public function setNext(?DelimiterInterface $next): void;
|
||||
|
||||
public function getLength(): int;
|
||||
|
||||
public function setLength(int $length): void;
|
||||
|
||||
public function getOriginalLength(): int;
|
||||
|
||||
public function getInlineNode(): AbstractStringContainer;
|
||||
|
||||
public function getPrevious(): ?DelimiterInterface;
|
||||
|
||||
public function setPrevious(?DelimiterInterface $previous): void;
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace League\CommonMark\Delimiter;
|
||||
|
||||
use League\CommonMark\Delimiter\Processor\DelimiterProcessorCollection;
|
||||
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
|
||||
use League\CommonMark\Node\Inline\Text;
|
||||
use League\CommonMark\Parser\Inline\InlineParserInterface;
|
||||
use League\CommonMark\Parser\Inline\InlineParserMatch;
|
||||
use League\CommonMark\Parser\InlineParserContext;
|
||||
use League\CommonMark\Util\RegexHelper;
|
||||
|
||||
/**
|
||||
* Delimiter parsing is implemented as an Inline Parser with the lowest-possible priority
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class DelimiterParser implements InlineParserInterface
|
||||
{
|
||||
private DelimiterProcessorCollection $collection;
|
||||
|
||||
public function __construct(DelimiterProcessorCollection $collection)
|
||||
{
|
||||
$this->collection = $collection;
|
||||
}
|
||||
|
||||
public function getMatchDefinition(): InlineParserMatch
|
||||
{
|
||||
return InlineParserMatch::oneOf(...$this->collection->getDelimiterCharacters());
|
||||
}
|
||||
|
||||
public function parse(InlineParserContext $inlineContext): bool
|
||||
{
|
||||
$character = $inlineContext->getFullMatch();
|
||||
$numDelims = 0;
|
||||
$cursor = $inlineContext->getCursor();
|
||||
$processor = $this->collection->getDelimiterProcessor($character);
|
||||
|
||||
\assert($processor !== null); // Delimiter processor should never be null here
|
||||
|
||||
$charBefore = $cursor->peek(-1);
|
||||
if ($charBefore === null) {
|
||||
$charBefore = "\n";
|
||||
}
|
||||
|
||||
while ($cursor->peek($numDelims) === $character) {
|
||||
++$numDelims;
|
||||
}
|
||||
|
||||
if ($numDelims < $processor->getMinLength()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cursor->advanceBy($numDelims);
|
||||
|
||||
$charAfter = $cursor->getCurrentCharacter();
|
||||
if ($charAfter === null) {
|
||||
$charAfter = "\n";
|
||||
}
|
||||
|
||||
[$canOpen, $canClose] = self::determineCanOpenOrClose($charBefore, $charAfter, $character, $processor);
|
||||
|
||||
if (! ($canOpen || $canClose)) {
|
||||
$inlineContext->getContainer()->appendChild(new Text(\str_repeat($character, $numDelims)));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$node = new Text(\str_repeat($character, $numDelims), [
|
||||
'delim' => true,
|
||||
]);
|
||||
$inlineContext->getContainer()->appendChild($node);
|
||||
|
||||
// Add entry to stack to this opener
|
||||
$delimiter = new Delimiter($character, $numDelims, $node, $canOpen, $canClose, $inlineContext->getCursor()->getPosition());
|
||||
$inlineContext->getDelimiterStack()->push($delimiter);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool[]
|
||||
*/
|
||||
private static function determineCanOpenOrClose(string $charBefore, string $charAfter, string $character, DelimiterProcessorInterface $delimiterProcessor): array
|
||||
{
|
||||
$afterIsWhitespace = \preg_match(RegexHelper::REGEX_UNICODE_WHITESPACE_CHAR, $charAfter);
|
||||
$afterIsPunctuation = \preg_match(RegexHelper::REGEX_PUNCTUATION, $charAfter);
|
||||
$beforeIsWhitespace = \preg_match(RegexHelper::REGEX_UNICODE_WHITESPACE_CHAR, $charBefore);
|
||||
$beforeIsPunctuation = \preg_match(RegexHelper::REGEX_PUNCTUATION, $charBefore);
|
||||
|
||||
$leftFlanking = ! $afterIsWhitespace && (! $afterIsPunctuation || $beforeIsWhitespace || $beforeIsPunctuation);
|
||||
$rightFlanking = ! $beforeIsWhitespace && (! $beforeIsPunctuation || $afterIsWhitespace || $afterIsPunctuation);
|
||||
|
||||
if ($character === '_') {
|
||||
$canOpen = $leftFlanking && (! $rightFlanking || $beforeIsPunctuation);
|
||||
$canClose = $rightFlanking && (! $leftFlanking || $afterIsPunctuation);
|
||||
} else {
|
||||
$canOpen = $leftFlanking && $character === $delimiterProcessor->getOpeningCharacter();
|
||||
$canClose = $rightFlanking && $character === $delimiterProcessor->getClosingCharacter();
|
||||
}
|
||||
|
||||
return [$canOpen, $canClose];
|
||||
}
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* Additional emphasis processing code based on commonmark-java (https://github.com/atlassian/commonmark-java)
|
||||
* - (c) Atlassian Pty Ltd
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Delimiter;
|
||||
|
||||
use League\CommonMark\Delimiter\Processor\CacheableDelimiterProcessorInterface;
|
||||
use League\CommonMark\Delimiter\Processor\DelimiterProcessorCollection;
|
||||
use League\CommonMark\Node\Inline\AdjacentTextMerger;
|
||||
use League\CommonMark\Node\Node;
|
||||
|
||||
final class DelimiterStack
|
||||
{
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private ?DelimiterInterface $top = null;
|
||||
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private ?Bracket $brackets = null;
|
||||
|
||||
/**
|
||||
* @deprecated This property will be removed in 3.0 once all delimiters MUST have an index/position
|
||||
*
|
||||
* @var \SplObjectStorage<DelimiterInterface, int>|\WeakMap<DelimiterInterface, int>
|
||||
*/
|
||||
private $missingIndexCache;
|
||||
|
||||
|
||||
private int $remainingDelimiters = 0;
|
||||
|
||||
public function __construct(int $maximumStackSize = PHP_INT_MAX)
|
||||
{
|
||||
$this->remainingDelimiters = $maximumStackSize;
|
||||
|
||||
if (\PHP_VERSION_ID >= 80000) {
|
||||
/** @psalm-suppress PropertyTypeCoercion */
|
||||
$this->missingIndexCache = new \WeakMap(); // @phpstan-ignore-line
|
||||
} else {
|
||||
$this->missingIndexCache = new \SplObjectStorage(); // @phpstan-ignore-line
|
||||
}
|
||||
}
|
||||
|
||||
public function push(DelimiterInterface $newDelimiter): void
|
||||
{
|
||||
if ($this->remainingDelimiters-- <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newDelimiter->setPrevious($this->top);
|
||||
|
||||
if ($this->top !== null) {
|
||||
$this->top->setNext($newDelimiter);
|
||||
}
|
||||
|
||||
$this->top = $newDelimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function addBracket(Node $node, int $index, bool $image): void
|
||||
{
|
||||
if ($this->brackets !== null) {
|
||||
$this->brackets->setHasNext(true);
|
||||
}
|
||||
|
||||
$this->brackets = new Bracket($node, $this->brackets, $index, $image);
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-immutable
|
||||
*/
|
||||
public function getLastBracket(): ?Bracket
|
||||
{
|
||||
return $this->brackets;
|
||||
}
|
||||
|
||||
private function findEarliest(int $stackBottom): ?DelimiterInterface
|
||||
{
|
||||
// Move back to first relevant delim.
|
||||
$delimiter = $this->top;
|
||||
$lastChecked = null;
|
||||
|
||||
while ($delimiter !== null && self::getIndex($delimiter) > $stackBottom) {
|
||||
$lastChecked = $delimiter;
|
||||
$delimiter = $delimiter->getPrevious();
|
||||
}
|
||||
|
||||
return $lastChecked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function removeBracket(): void
|
||||
{
|
||||
if ($this->brackets === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->brackets = $this->brackets->getPrevious();
|
||||
|
||||
if ($this->brackets !== null) {
|
||||
$this->brackets->setHasNext(false);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDelimiter(DelimiterInterface $delimiter): void
|
||||
{
|
||||
if ($delimiter->getPrevious() !== null) {
|
||||
/** @psalm-suppress PossiblyNullReference */
|
||||
$delimiter->getPrevious()->setNext($delimiter->getNext());
|
||||
}
|
||||
|
||||
if ($delimiter->getNext() === null) {
|
||||
// top of stack
|
||||
$this->top = $delimiter->getPrevious();
|
||||
} else {
|
||||
/** @psalm-suppress PossiblyNullReference */
|
||||
$delimiter->getNext()->setPrevious($delimiter->getPrevious());
|
||||
}
|
||||
|
||||
// Nullify all references from the removed delimiter to other delimiters.
|
||||
// All references to this particular delimiter in the linked list should be gone,
|
||||
// but it's possible we're still hanging on to other references to things that
|
||||
// have been (or soon will be) removed, which may interfere with efficient
|
||||
// garbage collection by the PHP runtime.
|
||||
// Explicitly releasing these references should help to avoid possible
|
||||
// segfaults like in https://bugs.php.net/bug.php?id=68606.
|
||||
$delimiter->setPrevious(null);
|
||||
$delimiter->setNext(null);
|
||||
|
||||
// TODO: Remove the line below once PHP 7.4 support is dropped, as WeakMap won't hold onto the reference, making this unnecessary
|
||||
unset($this->missingIndexCache[$delimiter]);
|
||||
}
|
||||
|
||||
private function removeDelimiterAndNode(DelimiterInterface $delimiter): void
|
||||
{
|
||||
$delimiter->getInlineNode()->detach();
|
||||
$this->removeDelimiter($delimiter);
|
||||
}
|
||||
|
||||
private function removeDelimitersBetween(DelimiterInterface $opener, DelimiterInterface $closer): void
|
||||
{
|
||||
$delimiter = $closer->getPrevious();
|
||||
$openerPosition = self::getIndex($opener);
|
||||
while ($delimiter !== null && self::getIndex($delimiter) > $openerPosition) {
|
||||
$previous = $delimiter->getPrevious();
|
||||
$this->removeDelimiter($delimiter);
|
||||
$delimiter = $previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DelimiterInterface|int|null $stackBottom
|
||||
*/
|
||||
public function removeAll($stackBottom = null): void
|
||||
{
|
||||
$stackBottomPosition = \is_int($stackBottom) ? $stackBottom : self::getIndex($stackBottom);
|
||||
|
||||
while ($this->top && $this->getIndex($this->top) > $stackBottomPosition) {
|
||||
$this->removeDelimiter($this->top);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated This method is no longer used internally and will be removed in 3.0
|
||||
*/
|
||||
public function removeEarlierMatches(string $character): void
|
||||
{
|
||||
$opener = $this->top;
|
||||
while ($opener !== null) {
|
||||
if ($opener->getChar() === $character) {
|
||||
$opener->setActive(false);
|
||||
}
|
||||
|
||||
$opener = $opener->getPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function deactivateLinkOpeners(): void
|
||||
{
|
||||
$opener = $this->brackets;
|
||||
while ($opener !== null && $opener->isActive()) {
|
||||
$opener->setActive(false);
|
||||
$opener = $opener->getPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated This method is no longer used internally and will be removed in 3.0
|
||||
*
|
||||
* @param string|string[] $characters
|
||||
*/
|
||||
public function searchByCharacter($characters): ?DelimiterInterface
|
||||
{
|
||||
if (! \is_array($characters)) {
|
||||
$characters = [$characters];
|
||||
}
|
||||
|
||||
$opener = $this->top;
|
||||
while ($opener !== null) {
|
||||
if (\in_array($opener->getChar(), $characters, true)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$opener = $opener->getPrevious();
|
||||
}
|
||||
|
||||
return $opener;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DelimiterInterface|int|null $stackBottom
|
||||
*
|
||||
* @todo change $stackBottom to an int in 3.0
|
||||
*/
|
||||
public function processDelimiters($stackBottom, DelimiterProcessorCollection $processors): void
|
||||
{
|
||||
/** @var array<string, int> $openersBottom */
|
||||
$openersBottom = [];
|
||||
|
||||
$stackBottomPosition = \is_int($stackBottom) ? $stackBottom : self::getIndex($stackBottom);
|
||||
|
||||
// Find first closer above stackBottom
|
||||
$closer = $this->findEarliest($stackBottomPosition);
|
||||
|
||||
// Move forward, looking for closers, and handling each
|
||||
while ($closer !== null) {
|
||||
$closingDelimiterChar = $closer->getChar();
|
||||
|
||||
$delimiterProcessor = $processors->getDelimiterProcessor($closingDelimiterChar);
|
||||
if (! $closer->canClose() || $delimiterProcessor === null) {
|
||||
$closer = $closer->getNext();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($delimiterProcessor instanceof CacheableDelimiterProcessorInterface) {
|
||||
$openersBottomCacheKey = $delimiterProcessor->getCacheKey($closer);
|
||||
} else {
|
||||
$openersBottomCacheKey = $closingDelimiterChar;
|
||||
}
|
||||
|
||||
$openingDelimiterChar = $delimiterProcessor->getOpeningCharacter();
|
||||
|
||||
$useDelims = 0;
|
||||
$openerFound = false;
|
||||
$potentialOpenerFound = false;
|
||||
$opener = $closer->getPrevious();
|
||||
while ($opener !== null && ($openerPosition = self::getIndex($opener)) > $stackBottomPosition && $openerPosition >= ($openersBottom[$openersBottomCacheKey] ?? 0)) {
|
||||
if ($opener->canOpen() && $opener->getChar() === $openingDelimiterChar) {
|
||||
$potentialOpenerFound = true;
|
||||
$useDelims = $delimiterProcessor->getDelimiterUse($opener, $closer);
|
||||
if ($useDelims > 0) {
|
||||
$openerFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$opener = $opener->getPrevious();
|
||||
}
|
||||
|
||||
if (! $openerFound) {
|
||||
// Set lower bound for future searches
|
||||
// TODO: Remove this conditional check in 3.0. It only exists to prevent behavioral BC breaks in 2.x.
|
||||
if ($potentialOpenerFound === false || $delimiterProcessor instanceof CacheableDelimiterProcessorInterface) {
|
||||
$openersBottom[$openersBottomCacheKey] = self::getIndex($closer);
|
||||
}
|
||||
|
||||
if (! $potentialOpenerFound && ! $closer->canOpen()) {
|
||||
// We can remove a closer that can't be an opener,
|
||||
// once we've seen there's no matching opener.
|
||||
$next = $closer->getNext();
|
||||
$this->removeDelimiter($closer);
|
||||
$closer = $next;
|
||||
} else {
|
||||
$closer = $closer->getNext();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
\assert($opener !== null);
|
||||
|
||||
$openerNode = $opener->getInlineNode();
|
||||
$closerNode = $closer->getInlineNode();
|
||||
|
||||
// Remove number of used delimiters from stack and inline nodes.
|
||||
$opener->setLength($opener->getLength() - $useDelims);
|
||||
$closer->setLength($closer->getLength() - $useDelims);
|
||||
|
||||
$openerNode->setLiteral(\substr($openerNode->getLiteral(), 0, -$useDelims));
|
||||
$closerNode->setLiteral(\substr($closerNode->getLiteral(), 0, -$useDelims));
|
||||
|
||||
$this->removeDelimitersBetween($opener, $closer);
|
||||
// The delimiter processor can re-parent the nodes between opener and closer,
|
||||
// so make sure they're contiguous already. Exclusive because we want to keep opener/closer themselves.
|
||||
AdjacentTextMerger::mergeTextNodesBetweenExclusive($openerNode, $closerNode);
|
||||
$delimiterProcessor->process($openerNode, $closerNode, $useDelims);
|
||||
|
||||
// No delimiter characters left to process, so we can remove delimiter and the now empty node.
|
||||
if ($opener->getLength() === 0) {
|
||||
$this->removeDelimiterAndNode($opener);
|
||||
}
|
||||
|
||||
// phpcs:disable SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed
|
||||
if ($closer->getLength() === 0) {
|
||||
$next = $closer->getNext();
|
||||
$this->removeDelimiterAndNode($closer);
|
||||
$closer = $next;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all delimiters
|
||||
$this->removeAll($stackBottomPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
while ($this->top) {
|
||||
$this->removeDelimiter($this->top);
|
||||
}
|
||||
|
||||
while ($this->brackets) {
|
||||
$this->removeBracket();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated This method will be dropped in 3.0 once all delimiters MUST have an index/position
|
||||
*/
|
||||
private function getIndex(?DelimiterInterface $delimiter): int
|
||||
{
|
||||
if ($delimiter === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (($index = $delimiter->getIndex()) !== null) {
|
||||
return $index;
|
||||
}
|
||||
|
||||
if (isset($this->missingIndexCache[$delimiter])) {
|
||||
return $this->missingIndexCache[$delimiter];
|
||||
}
|
||||
|
||||
$prev = $delimiter->getPrevious();
|
||||
$next = $delimiter->getNext();
|
||||
|
||||
$i = 0;
|
||||
do {
|
||||
$i++;
|
||||
if ($prev === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($prev->getIndex() !== null) {
|
||||
return $this->missingIndexCache[$delimiter] = $prev->getIndex() + $i;
|
||||
}
|
||||
} while ($prev = $prev->getPrevious());
|
||||
|
||||
$j = 0;
|
||||
do {
|
||||
$j++;
|
||||
if ($next === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($next->getIndex() !== null) {
|
||||
return $this->missingIndexCache[$delimiter] = $next->getIndex() - $j;
|
||||
}
|
||||
} while ($next = $next->getNext());
|
||||
|
||||
// No index was defined on this delimiter, and none could be guesstimated based on the stack.
|
||||
return $this->missingIndexCache[$delimiter] = $this->getIndex($delimiter->getPrevious()) + 1;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* Additional emphasis processing code based on commonmark-java (https://github.com/atlassian/commonmark-java)
|
||||
* - (c) Atlassian Pty Ltd
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Delimiter\Processor;
|
||||
|
||||
use League\CommonMark\Delimiter\DelimiterInterface;
|
||||
use League\CommonMark\Node\Inline\AbstractStringContainer;
|
||||
|
||||
/**
|
||||
* Interface for a delimiter processor
|
||||
*/
|
||||
interface DelimiterProcessorInterface
|
||||
{
|
||||
/**
|
||||
* Returns the character that marks the beginning of a delimited node.
|
||||
*
|
||||
* This must not clash with any other processors being added to the environment.
|
||||
*/
|
||||
public function getOpeningCharacter(): string;
|
||||
|
||||
/**
|
||||
* Returns the character that marks the ending of a delimited node.
|
||||
*
|
||||
* This must not clash with any other processors being added to the environment.
|
||||
*
|
||||
* Note that for a symmetric delimiter such as "*", this is the same as the opening.
|
||||
*/
|
||||
public function getClosingCharacter(): string;
|
||||
|
||||
/**
|
||||
* Minimum number of delimiter characters that are needed to active this.
|
||||
*
|
||||
* Must be at least 1.
|
||||
*/
|
||||
public function getMinLength(): int;
|
||||
|
||||
/**
|
||||
* Determine how many (if any) of the delimiter characters should be used.
|
||||
*
|
||||
* This allows implementations to decide how many characters to be used
|
||||
* based on the properties of the delimiter runs. An implementation can also
|
||||
* return 0 when it doesn't want to allow this particular combination of
|
||||
* delimiter runs.
|
||||
*
|
||||
* IMPORTANT: Unless this method returns the same hard-coded value in all cases,
|
||||
* you MUST implement the CacheableDelimiterProcessorInterface interface instead.
|
||||
*
|
||||
* @param DelimiterInterface $opener The opening delimiter run
|
||||
* @param DelimiterInterface $closer The closing delimiter run
|
||||
*/
|
||||
public function getDelimiterUse(DelimiterInterface $opener, DelimiterInterface $closer): int;
|
||||
|
||||
/**
|
||||
* Process the matched delimiters, e.g. by wrapping the nodes between opener
|
||||
* and closer in a new node, or appending a new node after the opener.
|
||||
*
|
||||
* Note that removal of the delimiter from the delimiter nodes and detaching
|
||||
* them is done by the caller.
|
||||
*
|
||||
* @param AbstractStringContainer $opener The node that contained the opening delimiter
|
||||
* @param AbstractStringContainer $closer The node that contained the closing delimiter
|
||||
* @param int $delimiterUse The number of delimiters that were used
|
||||
*/
|
||||
public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse): void;
|
||||
}
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Environment;
|
||||
|
||||
use League\CommonMark\Delimiter\DelimiterParser;
|
||||
use League\CommonMark\Delimiter\Processor\DelimiterProcessorCollection;
|
||||
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
|
||||
use League\CommonMark\Event\DocumentParsedEvent;
|
||||
use League\CommonMark\Event\ListenerData;
|
||||
use League\CommonMark\Exception\AlreadyInitializedException;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\ConfigurableExtensionInterface;
|
||||
use League\CommonMark\Extension\ExtensionInterface;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\Normalizer\SlugNormalizer;
|
||||
use League\CommonMark\Normalizer\TextNormalizerInterface;
|
||||
use League\CommonMark\Normalizer\UniqueSlugNormalizer;
|
||||
use League\CommonMark\Normalizer\UniqueSlugNormalizerInterface;
|
||||
use League\CommonMark\Parser\Block\BlockStartParserInterface;
|
||||
use League\CommonMark\Parser\Block\SkipLinesStartingWithLettersParser;
|
||||
use League\CommonMark\Parser\Inline\InlineParserInterface;
|
||||
use League\CommonMark\Renderer\NodeRendererInterface;
|
||||
use League\CommonMark\Util\HtmlFilter;
|
||||
use League\CommonMark\Util\PrioritizedList;
|
||||
use League\Config\Configuration;
|
||||
use League\Config\ConfigurationAwareInterface;
|
||||
use League\Config\ConfigurationInterface;
|
||||
use Nette\Schema\Expect;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\ListenerProviderInterface;
|
||||
use Psr\EventDispatcher\StoppableEventInterface;
|
||||
|
||||
final class Environment implements EnvironmentInterface, EnvironmentBuilderInterface, ListenerProviderInterface
|
||||
{
|
||||
/**
|
||||
* @var ExtensionInterface[]
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private array $extensions = [];
|
||||
|
||||
/**
|
||||
* @var ExtensionInterface[]
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private array $uninitializedExtensions = [];
|
||||
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private bool $extensionsInitialized = false;
|
||||
|
||||
/**
|
||||
* @var PrioritizedList<BlockStartParserInterface>
|
||||
*
|
||||
* @psalm-readonly
|
||||
*/
|
||||
private PrioritizedList $blockStartParsers;
|
||||
|
||||
/**
|
||||
* @var PrioritizedList<InlineParserInterface>
|
||||
*
|
||||
* @psalm-readonly
|
||||
*/
|
||||
private PrioritizedList $inlineParsers;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private DelimiterProcessorCollection $delimiterProcessors;
|
||||
|
||||
/**
|
||||
* @var array<string, PrioritizedList<NodeRendererInterface>>
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private array $renderersByClass = [];
|
||||
|
||||
/**
|
||||
* @var PrioritizedList<ListenerData>
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private PrioritizedList $listenerData;
|
||||
|
||||
private ?EventDispatcherInterface $eventDispatcher = null;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private Configuration $config;
|
||||
|
||||
private ?TextNormalizerInterface $slugNormalizer = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = self::createDefaultConfiguration();
|
||||
$this->config->merge($config);
|
||||
|
||||
$this->blockStartParsers = new PrioritizedList();
|
||||
$this->inlineParsers = new PrioritizedList();
|
||||
$this->listenerData = new PrioritizedList();
|
||||
$this->delimiterProcessors = new DelimiterProcessorCollection();
|
||||
|
||||
// Performance optimization: always include a block "parser" that aborts parsing if a line starts with a letter
|
||||
// and is therefore unlikely to match any lines as a block start.
|
||||
$this->addBlockStartParser(new SkipLinesStartingWithLettersParser(), 249);
|
||||
}
|
||||
|
||||
public function getConfiguration(): ConfigurationInterface
|
||||
{
|
||||
return $this->config->reader();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Environment::mergeConfig() is deprecated since league/commonmark v2.0 and will be removed in v3.0. Configuration should be set when instantiating the environment instead.
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function mergeConfig(array $config): void
|
||||
{
|
||||
@\trigger_error('Environment::mergeConfig() is deprecated since league/commonmark v2.0 and will be removed in v3.0. Configuration should be set when instantiating the environment instead.', \E_USER_DEPRECATED);
|
||||
|
||||
$this->assertUninitialized('Failed to modify configuration.');
|
||||
|
||||
$this->config->merge($config);
|
||||
}
|
||||
|
||||
public function addBlockStartParser(BlockStartParserInterface $parser, int $priority = 0): EnvironmentBuilderInterface
|
||||
{
|
||||
$this->assertUninitialized('Failed to add block start parser.');
|
||||
|
||||
$this->blockStartParsers->add($parser, $priority);
|
||||
$this->injectEnvironmentAndConfigurationIfNeeded($parser);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addInlineParser(InlineParserInterface $parser, int $priority = 0): EnvironmentBuilderInterface
|
||||
{
|
||||
$this->assertUninitialized('Failed to add inline parser.');
|
||||
|
||||
$this->inlineParsers->add($parser, $priority);
|
||||
$this->injectEnvironmentAndConfigurationIfNeeded($parser);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addDelimiterProcessor(DelimiterProcessorInterface $processor): EnvironmentBuilderInterface
|
||||
{
|
||||
$this->assertUninitialized('Failed to add delimiter processor.');
|
||||
$this->delimiterProcessors->add($processor);
|
||||
$this->injectEnvironmentAndConfigurationIfNeeded($processor);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addRenderer(string $nodeClass, NodeRendererInterface $renderer, int $priority = 0): EnvironmentBuilderInterface
|
||||
{
|
||||
$this->assertUninitialized('Failed to add renderer.');
|
||||
|
||||
if (! isset($this->renderersByClass[$nodeClass])) {
|
||||
$this->renderersByClass[$nodeClass] = new PrioritizedList();
|
||||
}
|
||||
|
||||
$this->renderersByClass[$nodeClass]->add($renderer, $priority);
|
||||
$this->injectEnvironmentAndConfigurationIfNeeded($renderer);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getBlockStartParsers(): iterable
|
||||
{
|
||||
if (! $this->extensionsInitialized) {
|
||||
$this->initializeExtensions();
|
||||
}
|
||||
|
||||
return $this->blockStartParsers->getIterator();
|
||||
}
|
||||
|
||||
public function getDelimiterProcessors(): DelimiterProcessorCollection
|
||||
{
|
||||
if (! $this->extensionsInitialized) {
|
||||
$this->initializeExtensions();
|
||||
}
|
||||
|
||||
return $this->delimiterProcessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getRenderersForClass(string $nodeClass): iterable
|
||||
{
|
||||
if (! $this->extensionsInitialized) {
|
||||
$this->initializeExtensions();
|
||||
}
|
||||
|
||||
// If renderers are defined for this specific class, return them immediately
|
||||
if (isset($this->renderersByClass[$nodeClass])) {
|
||||
return $this->renderersByClass[$nodeClass];
|
||||
}
|
||||
|
||||
/** @psalm-suppress TypeDoesNotContainType -- Bug: https://github.com/vimeo/psalm/issues/3332 */
|
||||
while (\class_exists($parent ??= $nodeClass) && $parent = \get_parent_class($parent)) {
|
||||
if (! isset($this->renderersByClass[$parent])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// "Cache" this result to avoid future loops
|
||||
return $this->renderersByClass[$nodeClass] = $this->renderersByClass[$parent];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getExtensions(): iterable
|
||||
{
|
||||
return $this->extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single extension
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addExtension(ExtensionInterface $extension): EnvironmentBuilderInterface
|
||||
{
|
||||
$this->assertUninitialized('Failed to add extension.');
|
||||
|
||||
$this->extensions[] = $extension;
|
||||
$this->uninitializedExtensions[] = $extension;
|
||||
|
||||
if ($extension instanceof ConfigurableExtensionInterface) {
|
||||
$extension->configureSchema($this->config);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function initializeExtensions(): void
|
||||
{
|
||||
// Initialize the slug normalizer
|
||||
$this->getSlugNormalizer();
|
||||
|
||||
// Ask all extensions to register their components
|
||||
while (\count($this->uninitializedExtensions) > 0) {
|
||||
foreach ($this->uninitializedExtensions as $i => $extension) {
|
||||
$extension->register($this);
|
||||
unset($this->uninitializedExtensions[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->extensionsInitialized = true;
|
||||
|
||||
// Create the special delimiter parser if any processors were registered
|
||||
if ($this->delimiterProcessors->count() > 0) {
|
||||
$this->inlineParsers->add(new DelimiterParser($this->delimiterProcessors), PHP_INT_MIN);
|
||||
}
|
||||
}
|
||||
|
||||
private function injectEnvironmentAndConfigurationIfNeeded(object $object): void
|
||||
{
|
||||
if ($object instanceof EnvironmentAwareInterface) {
|
||||
$object->setEnvironment($this);
|
||||
}
|
||||
|
||||
if ($object instanceof ConfigurationAwareInterface) {
|
||||
$object->setConfiguration($this->config->reader());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Instantiate the environment and add the extension yourself
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public static function createCommonMarkEnvironment(array $config = []): Environment
|
||||
{
|
||||
$environment = new self($config);
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Instantiate the environment and add the extension yourself
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public static function createGFMEnvironment(array $config = []): Environment
|
||||
{
|
||||
$environment = new self($config);
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension());
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
public function addEventListener(string $eventClass, callable $listener, int $priority = 0): EnvironmentBuilderInterface
|
||||
{
|
||||
$this->assertUninitialized('Failed to add event listener.');
|
||||
|
||||
$this->listenerData->add(new ListenerData($eventClass, $listener), $priority);
|
||||
|
||||
if (\is_object($listener)) {
|
||||
$this->injectEnvironmentAndConfigurationIfNeeded($listener);
|
||||
} elseif (\is_array($listener) && \is_object($listener[0])) {
|
||||
$this->injectEnvironmentAndConfigurationIfNeeded($listener[0]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function dispatch(object $event): object
|
||||
{
|
||||
if (! $this->extensionsInitialized) {
|
||||
$this->initializeExtensions();
|
||||
}
|
||||
|
||||
if ($this->eventDispatcher !== null) {
|
||||
return $this->eventDispatcher->dispatch($event);
|
||||
}
|
||||
|
||||
foreach ($this->getListenersForEvent($event) as $listener) {
|
||||
if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$listener($event);
|
||||
}
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
public function setEventDispatcher(EventDispatcherInterface $dispatcher): void
|
||||
{
|
||||
$this->eventDispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @return iterable<callable>
|
||||
*/
|
||||
public function getListenersForEvent(object $event): iterable
|
||||
{
|
||||
foreach ($this->listenerData as $listenerData) {
|
||||
\assert($listenerData instanceof ListenerData);
|
||||
|
||||
/** @psalm-suppress ArgumentTypeCoercion */
|
||||
if (! \is_a($event, $listenerData->getEvent())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield function (object $event) use ($listenerData) {
|
||||
if (! $this->extensionsInitialized) {
|
||||
$this->initializeExtensions();
|
||||
}
|
||||
|
||||
return \call_user_func($listenerData->getListener(), $event);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<InlineParserInterface>
|
||||
*/
|
||||
public function getInlineParsers(): iterable
|
||||
{
|
||||
if (! $this->extensionsInitialized) {
|
||||
$this->initializeExtensions();
|
||||
}
|
||||
|
||||
return $this->inlineParsers->getIterator();
|
||||
}
|
||||
|
||||
public function getSlugNormalizer(): TextNormalizerInterface
|
||||
{
|
||||
if ($this->slugNormalizer === null) {
|
||||
$normalizer = $this->config->get('slug_normalizer/instance');
|
||||
\assert($normalizer instanceof TextNormalizerInterface);
|
||||
$this->injectEnvironmentAndConfigurationIfNeeded($normalizer);
|
||||
|
||||
if ($this->config->get('slug_normalizer/unique') !== UniqueSlugNormalizerInterface::DISABLED && ! $normalizer instanceof UniqueSlugNormalizer) {
|
||||
$normalizer = new UniqueSlugNormalizer($normalizer);
|
||||
}
|
||||
|
||||
if ($normalizer instanceof UniqueSlugNormalizer) {
|
||||
if ($this->config->get('slug_normalizer/unique') === UniqueSlugNormalizerInterface::PER_DOCUMENT) {
|
||||
$this->addEventListener(DocumentParsedEvent::class, [$normalizer, 'clearHistory'], -1000);
|
||||
}
|
||||
}
|
||||
|
||||
$this->slugNormalizer = $normalizer;
|
||||
}
|
||||
|
||||
return $this->slugNormalizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AlreadyInitializedException
|
||||
*/
|
||||
private function assertUninitialized(string $message): void
|
||||
{
|
||||
if ($this->extensionsInitialized) {
|
||||
throw new AlreadyInitializedException($message . ' Extensions have already been initialized.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function createDefaultConfiguration(): Configuration
|
||||
{
|
||||
return new Configuration([
|
||||
'html_input' => Expect::anyOf(HtmlFilter::STRIP, HtmlFilter::ALLOW, HtmlFilter::ESCAPE)->default(HtmlFilter::ALLOW),
|
||||
'allow_unsafe_links' => Expect::bool(true),
|
||||
'max_nesting_level' => Expect::type('int')->default(PHP_INT_MAX),
|
||||
'max_delimiters_per_line' => Expect::type('int')->default(PHP_INT_MAX),
|
||||
'renderer' => Expect::structure([
|
||||
'block_separator' => Expect::string("\n"),
|
||||
'inner_separator' => Expect::string("\n"),
|
||||
'soft_break' => Expect::string("\n"),
|
||||
]),
|
||||
'slug_normalizer' => Expect::structure([
|
||||
'instance' => Expect::type(TextNormalizerInterface::class)->default(new SlugNormalizer()),
|
||||
'max_length' => Expect::int()->min(0)->default(255),
|
||||
'unique' => Expect::anyOf(UniqueSlugNormalizerInterface::DISABLED, UniqueSlugNormalizerInterface::PER_ENVIRONMENT, UniqueSlugNormalizerInterface::PER_DOCUMENT)->default(UniqueSlugNormalizerInterface::PER_DOCUMENT),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Vendored
+142
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
* (c) 2015 Martin Hasoň <martin.hason@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace League\CommonMark\Extension\Attributes\Util;
|
||||
|
||||
use League\CommonMark\Node\Node;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Util\RegexHelper;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class AttributesHelper
|
||||
{
|
||||
private const SINGLE_ATTRIBUTE = '\s*([.]-?[_a-z][^\s}]*|[#][^\s}]+|' . RegexHelper::PARTIAL_ATTRIBUTENAME . RegexHelper::PARTIAL_ATTRIBUTEVALUESPEC . ')\s*';
|
||||
private const ATTRIBUTE_LIST = '/^{:?(' . self::SINGLE_ATTRIBUTE . ')+}/i';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function parseAttributes(Cursor $cursor): array
|
||||
{
|
||||
$state = $cursor->saveState();
|
||||
$cursor->advanceToNextNonSpaceOrNewline();
|
||||
|
||||
// Quick check to see if we might have attributes
|
||||
if ($cursor->getCharacter() !== '{') {
|
||||
$cursor->restoreState($state);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Attempt to match the entire attribute list expression
|
||||
// While this is less performant than checking for '{' now and '}' later, it simplifies
|
||||
// matching individual attributes since they won't need to look ahead for the closing '}'
|
||||
// while dealing with the fact that attributes can technically contain curly braces.
|
||||
// So we'll just match the start and end braces up front.
|
||||
$attributeExpression = $cursor->match(self::ATTRIBUTE_LIST);
|
||||
if ($attributeExpression === null) {
|
||||
$cursor->restoreState($state);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Trim the leading '{' or '{:' and the trailing '}'
|
||||
$attributeExpression = \ltrim(\substr($attributeExpression, 1, -1), ':');
|
||||
$attributeCursor = new Cursor($attributeExpression);
|
||||
|
||||
/** @var array<string, mixed> $attributes */
|
||||
$attributes = [];
|
||||
while ($attribute = \trim((string) $attributeCursor->match('/^' . self::SINGLE_ATTRIBUTE . '/i'))) {
|
||||
if ($attribute[0] === '#') {
|
||||
$attributes['id'] = \substr($attribute, 1);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($attribute[0] === '.') {
|
||||
$attributes['class'][] = \substr($attribute, 1);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @psalm-suppress PossiblyUndefinedArrayOffset */
|
||||
[$name, $value] = \explode('=', $attribute, 2);
|
||||
|
||||
if ($value === 'true') {
|
||||
$attributes[$name] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$first = $value[0];
|
||||
$last = \substr($value, -1);
|
||||
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'") && \strlen($value) > 1) {
|
||||
$value = \substr($value, 1, -1);
|
||||
}
|
||||
|
||||
if (\strtolower(\trim($name)) === 'class') {
|
||||
foreach (\array_filter(\explode(' ', \trim($value))) as $class) {
|
||||
$attributes['class'][] = $class;
|
||||
}
|
||||
} else {
|
||||
$attributes[\trim($name)] = \trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($attributes['class'])) {
|
||||
$attributes['class'] = \implode(' ', (array) $attributes['class']);
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Node|array<string, mixed> $attributes1
|
||||
* @param Node|array<string, mixed> $attributes2
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function mergeAttributes($attributes1, $attributes2): array
|
||||
{
|
||||
$attributes = [];
|
||||
foreach ([$attributes1, $attributes2] as $arg) {
|
||||
if ($arg instanceof Node) {
|
||||
$arg = $arg->data->get('attributes');
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $arg */
|
||||
$arg = (array) $arg;
|
||||
if (isset($arg['class'])) {
|
||||
if (\is_string($arg['class'])) {
|
||||
$arg['class'] = \array_filter(\explode(' ', \trim($arg['class'])));
|
||||
}
|
||||
|
||||
foreach ($arg['class'] as $class) {
|
||||
$attributes['class'][] = $class;
|
||||
}
|
||||
|
||||
unset($arg['class']);
|
||||
}
|
||||
|
||||
$attributes = \array_merge($attributes, $arg);
|
||||
}
|
||||
|
||||
if (isset($attributes['class'])) {
|
||||
$attributes['class'] = \implode(' ', $attributes['class']);
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\Autolink;
|
||||
|
||||
use League\CommonMark\Environment\EnvironmentBuilderInterface;
|
||||
use League\CommonMark\Extension\ConfigurableExtensionInterface;
|
||||
use League\Config\ConfigurationBuilderInterface;
|
||||
use Nette\Schema\Expect;
|
||||
|
||||
final class AutolinkExtension implements ConfigurableExtensionInterface
|
||||
{
|
||||
public function configureSchema(ConfigurationBuilderInterface $builder): void
|
||||
{
|
||||
$builder->addSchema('autolink', Expect::structure([
|
||||
'allowed_protocols' => Expect::listOf('string')->default(['http', 'https', 'ftp'])->mergeDefaults(false),
|
||||
'default_protocol' => Expect::string()->default('http'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function register(EnvironmentBuilderInterface $environment): void
|
||||
{
|
||||
$environment->addInlineParser(new EmailAutolinkParser());
|
||||
$environment->addInlineParser(new UrlAutolinkParser(
|
||||
$environment->getConfiguration()->get('autolink.allowed_protocols'),
|
||||
$environment->getConfiguration()->get('autolink.default_protocol'),
|
||||
));
|
||||
}
|
||||
}
|
||||
Vendored
+157
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\Autolink;
|
||||
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
||||
use League\CommonMark\Parser\Inline\InlineParserInterface;
|
||||
use League\CommonMark\Parser\Inline\InlineParserMatch;
|
||||
use League\CommonMark\Parser\InlineParserContext;
|
||||
|
||||
final class UrlAutolinkParser implements InlineParserInterface
|
||||
{
|
||||
private const ALLOWED_AFTER = [null, ' ', "\t", "\n", "\x0b", "\x0c", "\x0d", '*', '_', '~', '('];
|
||||
|
||||
// RegEx adapted from https://github.com/symfony/symfony/blob/6.3/src/Symfony/Component/Validator/Constraints/UrlValidator.php
|
||||
private const REGEX = '~
|
||||
(
|
||||
# Must start with a supported scheme + auth, or "www"
|
||||
(?:
|
||||
(?:%s):// # protocol
|
||||
(?:(?:(?:[\_\.\pL\pN-]|%%[0-9A-Fa-f]{2})+:)?((?:[\_\.\pL\pN-]|%%[0-9A-Fa-f]{2})+)@)? # basic auth
|
||||
|www\.)
|
||||
(?:
|
||||
(?:
|
||||
(?:xn--[a-z0-9-]++\.)*+xn--[a-z0-9-]++ # a domain name using punycode
|
||||
|
|
||||
(?:[\pL\pN\pS\pM\-\_]++\.){1,127}[\pL\pN\pM]++ # a multi-level domain name; total length must be 253 bytes or less
|
||||
|
|
||||
[a-z0-9\-\_]++ # a single-level domain name
|
||||
)\.?
|
||||
| # or
|
||||
\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} # an IP address
|
||||
| # or
|
||||
\[
|
||||
(?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::))))
|
||||
\] # an IPv6 address
|
||||
)
|
||||
(?::[0-9]+)? # a port (optional)
|
||||
(?:/ (?:[\pL\pN\-._\~!$&\'()*+,;=:@]|%%[0-9A-Fa-f]{2})* )* # a path
|
||||
(?:\? (?:[\pL\pN\-._\~!$&\'\[\]()*+,;=:@/?]|%%[0-9A-Fa-f]{2})* )? # a query (optional)
|
||||
(?:\# (?:[\pL\pN\-._\~!$&\'()*+,;=:@/?]|%%[0-9A-Fa-f]{2})* )? # a fragment (optional)
|
||||
)~ixu';
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*
|
||||
* @psalm-readonly
|
||||
*/
|
||||
private array $prefixes = ['www.'];
|
||||
|
||||
/**
|
||||
* @psalm-var non-empty-string
|
||||
*
|
||||
* @psalm-readonly
|
||||
*/
|
||||
private string $finalRegex;
|
||||
|
||||
private string $defaultProtocol;
|
||||
|
||||
/**
|
||||
* @param array<int, string> $allowedProtocols
|
||||
*/
|
||||
public function __construct(array $allowedProtocols = ['http', 'https', 'ftp'], string $defaultProtocol = 'http')
|
||||
{
|
||||
/**
|
||||
* @psalm-suppress PropertyTypeCoercion
|
||||
*/
|
||||
$this->finalRegex = \sprintf(self::REGEX, \implode('|', $allowedProtocols));
|
||||
|
||||
foreach ($allowedProtocols as $protocol) {
|
||||
$this->prefixes[] = $protocol . '://';
|
||||
}
|
||||
|
||||
$this->defaultProtocol = $defaultProtocol;
|
||||
}
|
||||
|
||||
public function getMatchDefinition(): InlineParserMatch
|
||||
{
|
||||
return InlineParserMatch::oneOf(...$this->prefixes);
|
||||
}
|
||||
|
||||
public function parse(InlineParserContext $inlineContext): bool
|
||||
{
|
||||
$cursor = $inlineContext->getCursor();
|
||||
|
||||
// Autolinks can only come at the beginning of a line, after whitespace, or certain delimiting characters
|
||||
$previousChar = $cursor->peek(-1);
|
||||
if (! \in_array($previousChar, self::ALLOWED_AFTER, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we have a valid URL
|
||||
if (! \preg_match($this->finalRegex, $cursor->getRemainder(), $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = $matches[0];
|
||||
|
||||
// Does the URL end with punctuation that should be stripped?
|
||||
if (\preg_match('/(.+?)([?!.,:*_~]+)$/', $url, $matches)) {
|
||||
// Add the punctuation later
|
||||
$url = $matches[1];
|
||||
}
|
||||
|
||||
// Does the URL end with something that looks like an entity reference?
|
||||
if (\preg_match('/(.+)(&[A-Za-z0-9]+;)$/', $url, $matches)) {
|
||||
$url = $matches[1];
|
||||
}
|
||||
|
||||
// Does the URL need unmatched parens chopped off?
|
||||
if (\substr($url, -1) === ')' && ($diff = self::diffParens($url)) > 0) {
|
||||
$url = \substr($url, 0, -$diff);
|
||||
}
|
||||
|
||||
$cursor->advanceBy(\mb_strlen($url, 'UTF-8'));
|
||||
|
||||
// Auto-prefix 'http(s)://' onto 'www' URLs
|
||||
if (\substr($url, 0, 4) === 'www.') {
|
||||
$inlineContext->getContainer()->appendChild(new Link($this->defaultProtocol . '://' . $url, $url));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$inlineContext->getContainer()->appendChild(new Link($url, $url));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-pure
|
||||
*/
|
||||
private static function diffParens(string $content): int
|
||||
{
|
||||
// Scan the entire autolink for the total number of parentheses.
|
||||
// If there is a greater number of closing parentheses than opening ones,
|
||||
// we don’t consider ANY of the last characters as part of the autolink,
|
||||
// in order to facilitate including an autolink inside a parenthesis.
|
||||
\preg_match_all('/[()]/', $content, $matches);
|
||||
|
||||
$charCount = ['(' => 0, ')' => 0];
|
||||
foreach ($matches[0] as $char) {
|
||||
$charCount[$char]++;
|
||||
}
|
||||
|
||||
return $charCount[')'] - $charCount['('];
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* Additional emphasis processing code based on commonmark-java (https://github.com/atlassian/commonmark-java)
|
||||
* - (c) Atlassian Pty Ltd
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Delimiter\Processor;
|
||||
|
||||
use League\CommonMark\Delimiter\DelimiterInterface;
|
||||
use League\CommonMark\Delimiter\Processor\CacheableDelimiterProcessorInterface;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Emphasis;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Strong;
|
||||
use League\CommonMark\Node\Inline\AbstractStringContainer;
|
||||
use League\Config\ConfigurationAwareInterface;
|
||||
use League\Config\ConfigurationInterface;
|
||||
|
||||
final class EmphasisDelimiterProcessor implements CacheableDelimiterProcessorInterface, ConfigurationAwareInterface
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private string $char;
|
||||
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private ConfigurationInterface $config;
|
||||
|
||||
/**
|
||||
* @param string $char The emphasis character to use (typically '*' or '_')
|
||||
*/
|
||||
public function __construct(string $char)
|
||||
{
|
||||
$this->char = $char;
|
||||
}
|
||||
|
||||
public function getOpeningCharacter(): string
|
||||
{
|
||||
return $this->char;
|
||||
}
|
||||
|
||||
public function getClosingCharacter(): string
|
||||
{
|
||||
return $this->char;
|
||||
}
|
||||
|
||||
public function getMinLength(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function getDelimiterUse(DelimiterInterface $opener, DelimiterInterface $closer): int
|
||||
{
|
||||
// "Multiple of 3" rule for internal delimiter runs
|
||||
if (($opener->canClose() || $closer->canOpen()) && $closer->getOriginalLength() % 3 !== 0 && ($opener->getOriginalLength() + $closer->getOriginalLength()) % 3 === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate actual number of delimiters used from this closer
|
||||
if ($opener->getLength() >= 2 && $closer->getLength() >= 2) {
|
||||
if ($this->config->get('commonmark/enable_strong')) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->config->get('commonmark/enable_em')) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse): void
|
||||
{
|
||||
if ($delimiterUse === 1) {
|
||||
$emphasis = new Emphasis($this->char);
|
||||
} elseif ($delimiterUse === 2) {
|
||||
$emphasis = new Strong($this->char . $this->char);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
$next = $opener->next();
|
||||
while ($next !== null && $next !== $closer) {
|
||||
$tmp = $next->next();
|
||||
$emphasis->appendChild($next);
|
||||
$next = $tmp;
|
||||
}
|
||||
|
||||
$opener->insertAfter($emphasis);
|
||||
}
|
||||
|
||||
public function setConfiguration(ConfigurationInterface $configuration): void
|
||||
{
|
||||
$this->config = $configuration;
|
||||
}
|
||||
|
||||
public function getCacheKey(DelimiterInterface $closer): string
|
||||
{
|
||||
return \sprintf(
|
||||
'%s-%s-%d-%d',
|
||||
$this->char,
|
||||
$closer->canOpen() ? 'canOpen' : 'cannotOpen',
|
||||
$closer->getOriginalLength() % 3,
|
||||
$closer->getLength(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Node\Block;
|
||||
|
||||
use League\CommonMark\Node\Block\AbstractBlock;
|
||||
use League\CommonMark\Node\Block\TightBlockInterface;
|
||||
|
||||
class ListBlock extends AbstractBlock implements TightBlockInterface
|
||||
{
|
||||
public const TYPE_BULLET = 'bullet';
|
||||
public const TYPE_ORDERED = 'ordered';
|
||||
|
||||
public const DELIM_PERIOD = 'period';
|
||||
public const DELIM_PAREN = 'paren';
|
||||
|
||||
protected bool $tight = false; // TODO Make lists tight by default in v3
|
||||
|
||||
/** @psalm-readonly */
|
||||
protected ListData $listData;
|
||||
|
||||
public function __construct(ListData $listData)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->listData = $listData;
|
||||
}
|
||||
|
||||
public function getListData(): ListData
|
||||
{
|
||||
return $this->listData;
|
||||
}
|
||||
|
||||
public function isTight(): bool
|
||||
{
|
||||
return $this->tight;
|
||||
}
|
||||
|
||||
public function setTight(bool $tight): void
|
||||
{
|
||||
$this->tight = $tight;
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Parser\Block;
|
||||
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
|
||||
use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
|
||||
use League\CommonMark\Parser\Block\BlockContinue;
|
||||
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Util\ArrayCollection;
|
||||
use League\CommonMark\Util\RegexHelper;
|
||||
|
||||
final class FencedCodeParser extends AbstractBlockContinueParser
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private FencedCode $block;
|
||||
|
||||
/** @var ArrayCollection<string> */
|
||||
private ArrayCollection $strings;
|
||||
|
||||
public function __construct(int $fenceLength, string $fenceChar, int $fenceOffset)
|
||||
{
|
||||
$this->block = new FencedCode($fenceLength, $fenceChar, $fenceOffset);
|
||||
$this->strings = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getBlock(): FencedCode
|
||||
{
|
||||
return $this->block;
|
||||
}
|
||||
|
||||
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
|
||||
{
|
||||
// Check for closing code fence
|
||||
if (! $cursor->isIndented() && $cursor->getNextNonSpaceCharacter() === $this->block->getChar()) {
|
||||
$match = RegexHelper::matchFirst('/^(?:`{3,}|~{3,})(?=[ \t]*$)/', $cursor->getLine(), $cursor->getNextNonSpacePosition());
|
||||
if ($match !== null && \strlen($match[0]) >= $this->block->getLength()) {
|
||||
// closing fence - we're at end of line, so we can finalize now
|
||||
return BlockContinue::finished();
|
||||
}
|
||||
}
|
||||
|
||||
// Skip optional spaces of fence offset
|
||||
// Optimization: don't attempt to match if we're at a non-space position
|
||||
if ($cursor->getNextNonSpacePosition() > $cursor->getPosition()) {
|
||||
$cursor->match('/^ {0,' . $this->block->getOffset() . '}/');
|
||||
}
|
||||
|
||||
return BlockContinue::at($cursor);
|
||||
}
|
||||
|
||||
public function addLine(string $line): void
|
||||
{
|
||||
$this->strings[] = $line;
|
||||
}
|
||||
|
||||
public function closeBlock(): void
|
||||
{
|
||||
// first line becomes info string
|
||||
$firstLine = $this->strings->first();
|
||||
if ($firstLine === false) {
|
||||
$firstLine = '';
|
||||
}
|
||||
|
||||
$this->block->setInfo(RegexHelper::unescape(\trim($firstLine)));
|
||||
|
||||
if ($this->strings->count() === 1) {
|
||||
$this->block->setLiteral('');
|
||||
} else {
|
||||
$this->block->setLiteral(\implode("\n", $this->strings->slice(1)) . "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Parser\Block;
|
||||
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\IndentedCode;
|
||||
use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
|
||||
use League\CommonMark\Parser\Block\BlockContinue;
|
||||
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Util\ArrayCollection;
|
||||
|
||||
final class IndentedCodeParser extends AbstractBlockContinueParser
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private IndentedCode $block;
|
||||
|
||||
/** @var ArrayCollection<string> */
|
||||
private ArrayCollection $strings;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->block = new IndentedCode();
|
||||
$this->strings = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getBlock(): IndentedCode
|
||||
{
|
||||
return $this->block;
|
||||
}
|
||||
|
||||
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
|
||||
{
|
||||
if ($cursor->isIndented()) {
|
||||
$cursor->advanceBy(Cursor::INDENT_LEVEL, true);
|
||||
|
||||
return BlockContinue::at($cursor);
|
||||
}
|
||||
|
||||
if ($cursor->isBlank()) {
|
||||
$cursor->advanceToNextNonSpaceOrTab();
|
||||
|
||||
return BlockContinue::at($cursor);
|
||||
}
|
||||
|
||||
return BlockContinue::none();
|
||||
}
|
||||
|
||||
public function addLine(string $line): void
|
||||
{
|
||||
$this->strings[] = $line;
|
||||
}
|
||||
|
||||
public function closeBlock(): void
|
||||
{
|
||||
$lines = $this->strings->toArray();
|
||||
|
||||
// Note that indented code block cannot be empty, so $lines will always have at least one non-empty element
|
||||
while (\preg_match('/^[ \t]*$/', \end($lines))) { // @phpstan-ignore-line
|
||||
\array_pop($lines);
|
||||
}
|
||||
|
||||
$this->block->setLiteral(\implode("\n", $lines) . "\n");
|
||||
$this->block->setEndLine($this->block->getStartLine() + \count($lines) - 1);
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Parser\Block;
|
||||
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListData;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
|
||||
use League\CommonMark\Node\Block\AbstractBlock;
|
||||
use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
|
||||
use League\CommonMark\Parser\Block\BlockContinue;
|
||||
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
|
||||
final class ListBlockParser extends AbstractBlockContinueParser
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private ListBlock $block;
|
||||
|
||||
public function __construct(ListData $listData)
|
||||
{
|
||||
$this->block = new ListBlock($listData);
|
||||
}
|
||||
|
||||
public function getBlock(): ListBlock
|
||||
{
|
||||
return $this->block;
|
||||
}
|
||||
|
||||
public function isContainer(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function canContain(AbstractBlock $childBlock): bool
|
||||
{
|
||||
return $childBlock instanceof ListItem;
|
||||
}
|
||||
|
||||
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
|
||||
{
|
||||
// List blocks themselves don't have any markers, only list items. So try to stay in the list.
|
||||
// If there is a block start other than list item, canContain makes sure that this list is closed.
|
||||
return BlockContinue::at($cursor);
|
||||
}
|
||||
|
||||
public function closeBlock(): void
|
||||
{
|
||||
$item = $this->block->firstChild();
|
||||
while ($item instanceof AbstractBlock) {
|
||||
// check for non-final list item ending with blank line:
|
||||
if ($item->next() !== null && self::endsWithBlankLine($item)) {
|
||||
$this->block->setTight(false);
|
||||
break;
|
||||
}
|
||||
|
||||
// recurse into children of list item, to see if there are spaces between any of them
|
||||
$subitem = $item->firstChild();
|
||||
while ($subitem instanceof AbstractBlock) {
|
||||
if ($subitem->next() && self::endsWithBlankLine($subitem)) {
|
||||
$this->block->setTight(false);
|
||||
break 2;
|
||||
}
|
||||
|
||||
$subitem = $subitem->next();
|
||||
}
|
||||
|
||||
$item = $item->next();
|
||||
}
|
||||
|
||||
$lastChild = $this->block->lastChild();
|
||||
if ($lastChild instanceof AbstractBlock) {
|
||||
$this->block->setEndLine($lastChild->getEndLine());
|
||||
}
|
||||
}
|
||||
|
||||
private static function endsWithBlankLine(AbstractBlock $block): bool
|
||||
{
|
||||
$next = $block->next();
|
||||
|
||||
return $next instanceof AbstractBlock && $block->getEndLine() !== $next->getStartLine() - 1;
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Parser\Block;
|
||||
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListData;
|
||||
use League\CommonMark\Parser\Block\BlockStart;
|
||||
use League\CommonMark\Parser\Block\BlockStartParserInterface;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Parser\MarkdownParserStateInterface;
|
||||
use League\CommonMark\Util\RegexHelper;
|
||||
use League\Config\ConfigurationAwareInterface;
|
||||
use League\Config\ConfigurationInterface;
|
||||
|
||||
final class ListBlockStartParser implements BlockStartParserInterface, ConfigurationAwareInterface
|
||||
{
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private ?ConfigurationInterface $config = null;
|
||||
|
||||
/**
|
||||
* @psalm-var non-empty-string|null
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private ?string $listMarkerRegex = null;
|
||||
|
||||
public function setConfiguration(ConfigurationInterface $configuration): void
|
||||
{
|
||||
$this->config = $configuration;
|
||||
}
|
||||
|
||||
public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserState): ?BlockStart
|
||||
{
|
||||
if ($cursor->isIndented()) {
|
||||
return BlockStart::none();
|
||||
}
|
||||
|
||||
$listData = $this->parseList($cursor, $parserState->getParagraphContent() !== null);
|
||||
if ($listData === null) {
|
||||
return BlockStart::none();
|
||||
}
|
||||
|
||||
$listItemParser = new ListItemParser($listData);
|
||||
|
||||
// prepend the list block if needed
|
||||
$matched = $parserState->getLastMatchedBlockParser();
|
||||
if (! ($matched instanceof ListBlockParser) || ! $listData->equals($matched->getBlock()->getListData())) {
|
||||
$listBlockParser = new ListBlockParser($listData);
|
||||
// We start out with assuming a list is tight. If we find a blank line, we set it to loose later.
|
||||
// TODO for 3.0: Just make them tight by default in the block so we can remove this call
|
||||
$listBlockParser->getBlock()->setTight(true);
|
||||
|
||||
return BlockStart::of($listBlockParser, $listItemParser)->at($cursor);
|
||||
}
|
||||
|
||||
return BlockStart::of($listItemParser)->at($cursor);
|
||||
}
|
||||
|
||||
private function parseList(Cursor $cursor, bool $inParagraph): ?ListData
|
||||
{
|
||||
$indent = $cursor->getIndent();
|
||||
|
||||
$tmpCursor = clone $cursor;
|
||||
$tmpCursor->advanceToNextNonSpaceOrTab();
|
||||
$rest = $tmpCursor->getRemainder();
|
||||
|
||||
if (\preg_match($this->listMarkerRegex ?? $this->generateListMarkerRegex(), $rest) === 1) {
|
||||
$data = new ListData();
|
||||
$data->markerOffset = $indent;
|
||||
$data->type = ListBlock::TYPE_BULLET;
|
||||
$data->delimiter = null;
|
||||
$data->bulletChar = $rest[0];
|
||||
$markerLength = 1;
|
||||
} elseif (($matches = RegexHelper::matchFirst('/^(\d{1,9})([.)])/', $rest)) && (! $inParagraph || $matches[1] === '1')) {
|
||||
$data = new ListData();
|
||||
$data->markerOffset = $indent;
|
||||
$data->type = ListBlock::TYPE_ORDERED;
|
||||
$data->start = (int) $matches[1];
|
||||
$data->delimiter = $matches[2] === '.' ? ListBlock::DELIM_PERIOD : ListBlock::DELIM_PAREN;
|
||||
$data->bulletChar = null;
|
||||
$markerLength = \strlen($matches[0]);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Make sure we have spaces after
|
||||
$nextChar = $tmpCursor->peek($markerLength);
|
||||
if (! ($nextChar === null || $nextChar === "\t" || $nextChar === ' ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If it interrupts paragraph, make sure first line isn't blank
|
||||
if ($inParagraph && ! RegexHelper::matchAt(RegexHelper::REGEX_NON_SPACE, $rest, $markerLength)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cursor->advanceToNextNonSpaceOrTab(); // to start of marker
|
||||
$cursor->advanceBy($markerLength, true); // to end of marker
|
||||
$data->padding = self::calculateListMarkerPadding($cursor, $markerLength);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function calculateListMarkerPadding(Cursor $cursor, int $markerLength): int
|
||||
{
|
||||
$start = $cursor->saveState();
|
||||
$spacesStartCol = $cursor->getColumn();
|
||||
|
||||
while ($cursor->getColumn() - $spacesStartCol < 5) {
|
||||
if (! $cursor->advanceBySpaceOrTab()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$blankItem = $cursor->peek() === null;
|
||||
$spacesAfterMarker = $cursor->getColumn() - $spacesStartCol;
|
||||
|
||||
if ($spacesAfterMarker >= 5 || $spacesAfterMarker < 1 || $blankItem) {
|
||||
$cursor->restoreState($start);
|
||||
$cursor->advanceBySpaceOrTab();
|
||||
|
||||
return $markerLength + 1;
|
||||
}
|
||||
|
||||
return $markerLength + $spacesAfterMarker;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-return non-empty-string
|
||||
*/
|
||||
private function generateListMarkerRegex(): string
|
||||
{
|
||||
// No configuration given - use the defaults
|
||||
if ($this->config === null) {
|
||||
return $this->listMarkerRegex = '/^[*+-]/';
|
||||
}
|
||||
|
||||
$markers = $this->config->get('commonmark/unordered_list_markers');
|
||||
\assert(\is_array($markers));
|
||||
|
||||
return $this->listMarkerRegex = '/^[' . \preg_quote(\implode('', $markers), '/') . ']/';
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Parser\Block;
|
||||
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListData;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
|
||||
use League\CommonMark\Node\Block\AbstractBlock;
|
||||
use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
|
||||
use League\CommonMark\Parser\Block\BlockContinue;
|
||||
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
|
||||
final class ListItemParser extends AbstractBlockContinueParser
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private ListItem $block;
|
||||
|
||||
public function __construct(ListData $listData)
|
||||
{
|
||||
$this->block = new ListItem($listData);
|
||||
}
|
||||
|
||||
public function getBlock(): ListItem
|
||||
{
|
||||
return $this->block;
|
||||
}
|
||||
|
||||
public function isContainer(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function canContain(AbstractBlock $childBlock): bool
|
||||
{
|
||||
return ! $childBlock instanceof ListItem;
|
||||
}
|
||||
|
||||
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
|
||||
{
|
||||
if ($cursor->isBlank()) {
|
||||
if ($this->block->firstChild() === null) {
|
||||
// Blank line after empty list item
|
||||
return BlockContinue::none();
|
||||
}
|
||||
|
||||
$cursor->advanceToNextNonSpaceOrTab();
|
||||
|
||||
return BlockContinue::at($cursor);
|
||||
}
|
||||
|
||||
$contentIndent = $this->block->getListData()->markerOffset + $this->getBlock()->getListData()->padding;
|
||||
if ($cursor->getIndent() >= $contentIndent) {
|
||||
$cursor->advanceBy($contentIndent, true);
|
||||
|
||||
return BlockContinue::at($cursor);
|
||||
}
|
||||
|
||||
// Note: We'll hit this case for lazy continuation lines, they will get added later.
|
||||
return BlockContinue::none();
|
||||
}
|
||||
|
||||
public function closeBlock(): void
|
||||
{
|
||||
if (($lastChild = $this->block->lastChild()) instanceof AbstractBlock) {
|
||||
$this->block->setEndLine($lastChild->getEndLine());
|
||||
} else {
|
||||
// Empty list item
|
||||
$this->block->setEndLine($this->block->getStartLine());
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Parser\Inline;
|
||||
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Code;
|
||||
use League\CommonMark\Node\Inline\Text;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Parser\Inline\InlineParserInterface;
|
||||
use League\CommonMark\Parser\Inline\InlineParserMatch;
|
||||
use League\CommonMark\Parser\InlineParserContext;
|
||||
|
||||
final class BacktickParser implements InlineParserInterface
|
||||
{
|
||||
/**
|
||||
* Max bound for backtick code span delimiters.
|
||||
*
|
||||
* @see https://github.com/commonmark/cmark/commit/8ed5c9d
|
||||
*/
|
||||
private const MAX_BACKTICKS = 1000;
|
||||
|
||||
/** @var \WeakReference<Cursor>|null */
|
||||
private ?\WeakReference $lastCursor = null;
|
||||
private bool $lastCursorScanned = false;
|
||||
|
||||
/** @var array<int, int> backtick count => position of known ender */
|
||||
private array $seenBackticks = [];
|
||||
|
||||
public function getMatchDefinition(): InlineParserMatch
|
||||
{
|
||||
return InlineParserMatch::regex('`+');
|
||||
}
|
||||
|
||||
public function parse(InlineParserContext $inlineContext): bool
|
||||
{
|
||||
$ticks = $inlineContext->getFullMatch();
|
||||
$cursor = $inlineContext->getCursor();
|
||||
$cursor->advanceBy($inlineContext->getFullMatchLength());
|
||||
|
||||
$currentPosition = $cursor->getPosition();
|
||||
$previousState = $cursor->saveState();
|
||||
|
||||
if ($this->findMatchingTicks(\strlen($ticks), $cursor)) {
|
||||
$code = $cursor->getSubstring($currentPosition, $cursor->getPosition() - $currentPosition - \strlen($ticks));
|
||||
|
||||
$c = \preg_replace('/\n/m', ' ', $code) ?? '';
|
||||
|
||||
if (
|
||||
$c !== '' &&
|
||||
$c[0] === ' ' &&
|
||||
\substr($c, -1, 1) === ' ' &&
|
||||
\preg_match('/[^ ]/', $c)
|
||||
) {
|
||||
$c = \substr($c, 1, -1);
|
||||
}
|
||||
|
||||
$inlineContext->getContainer()->appendChild(new Code($c));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we got here, we didn't match a closing backtick sequence
|
||||
$cursor->restoreState($previousState);
|
||||
$inlineContext->getContainer()->appendChild(new Text($ticks));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the matching closer for a backtick code span.
|
||||
*
|
||||
* Leverages some caching to avoid traversing the same cursor multiple times when
|
||||
* we've already seen all the potential backtick closers.
|
||||
*
|
||||
* @see https://github.com/commonmark/cmark/commit/8ed5c9d
|
||||
*
|
||||
* @param int $openTickLength Number of backticks in the opening sequence
|
||||
* @param Cursor $cursor Cursor to scan
|
||||
*
|
||||
* @return bool True if a matching closer was found, false otherwise
|
||||
*/
|
||||
private function findMatchingTicks(int $openTickLength, Cursor $cursor): bool
|
||||
{
|
||||
// Reset the seenBackticks cache if this is a new cursor
|
||||
if ($this->lastCursor === null || $this->lastCursor->get() !== $cursor) {
|
||||
$this->seenBackticks = [];
|
||||
$this->lastCursor = \WeakReference::create($cursor);
|
||||
$this->lastCursorScanned = false;
|
||||
}
|
||||
|
||||
if ($openTickLength > self::MAX_BACKTICKS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Return if we already know there's no closer
|
||||
if ($this->lastCursorScanned && isset($this->seenBackticks[$openTickLength]) && $this->seenBackticks[$openTickLength] <= $cursor->getPosition()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
while ($ticks = $cursor->match('/`{1,' . self::MAX_BACKTICKS . '}/m')) {
|
||||
$numTicks = \strlen($ticks);
|
||||
|
||||
// Did we find the closer?
|
||||
if ($numTicks === $openTickLength) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Store position of closer
|
||||
if ($numTicks <= self::MAX_BACKTICKS) {
|
||||
$this->seenBackticks[$numTicks] = $cursor->getPosition() - $numTicks;
|
||||
}
|
||||
}
|
||||
|
||||
// Got through whole input without finding closer
|
||||
$this->lastCursorScanned = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
docker/streamline-src/vendor/league/commonmark/src/Extension/CommonMark/Parser/Inline/BangParser.php
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Parser\Inline;
|
||||
|
||||
use League\CommonMark\Node\Inline\Text;
|
||||
use League\CommonMark\Parser\Inline\InlineParserInterface;
|
||||
use League\CommonMark\Parser\Inline\InlineParserMatch;
|
||||
use League\CommonMark\Parser\InlineParserContext;
|
||||
|
||||
final class BangParser implements InlineParserInterface
|
||||
{
|
||||
public function getMatchDefinition(): InlineParserMatch
|
||||
{
|
||||
return InlineParserMatch::string('![');
|
||||
}
|
||||
|
||||
public function parse(InlineParserContext $inlineContext): bool
|
||||
{
|
||||
$cursor = $inlineContext->getCursor();
|
||||
$cursor->advanceBy(2);
|
||||
|
||||
$node = new Text('![', ['delim' => true]);
|
||||
$inlineContext->getContainer()->appendChild($node);
|
||||
|
||||
// Add entry to stack for this opener
|
||||
$inlineContext->getDelimiterStack()->addBracket($node, $cursor->getPosition(), true);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Parser\Inline;
|
||||
|
||||
use League\CommonMark\Delimiter\Bracket;
|
||||
use League\CommonMark\Environment\EnvironmentAwareInterface;
|
||||
use League\CommonMark\Environment\EnvironmentInterface;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\AbstractWebResource;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
||||
use League\CommonMark\Extension\Mention\Mention;
|
||||
use League\CommonMark\Node\Inline\AdjacentTextMerger;
|
||||
use League\CommonMark\Node\Inline\Text;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Parser\Inline\InlineParserInterface;
|
||||
use League\CommonMark\Parser\Inline\InlineParserMatch;
|
||||
use League\CommonMark\Parser\InlineParserContext;
|
||||
use League\CommonMark\Reference\ReferenceInterface;
|
||||
use League\CommonMark\Reference\ReferenceMapInterface;
|
||||
use League\CommonMark\Util\LinkParserHelper;
|
||||
use League\CommonMark\Util\RegexHelper;
|
||||
|
||||
final class CloseBracketParser implements InlineParserInterface, EnvironmentAwareInterface
|
||||
{
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private EnvironmentInterface $environment;
|
||||
|
||||
public function getMatchDefinition(): InlineParserMatch
|
||||
{
|
||||
return InlineParserMatch::string(']');
|
||||
}
|
||||
|
||||
public function parse(InlineParserContext $inlineContext): bool
|
||||
{
|
||||
// Look through stack of delimiters for a [ or !
|
||||
$opener = $inlineContext->getDelimiterStack()->getLastBracket();
|
||||
if ($opener === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $opener->isImage() && ! $opener->isActive()) {
|
||||
// no matched opener; remove from stack
|
||||
$inlineContext->getDelimiterStack()->removeBracket();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$cursor = $inlineContext->getCursor();
|
||||
|
||||
$startPos = $cursor->getPosition();
|
||||
$previousState = $cursor->saveState();
|
||||
|
||||
$cursor->advanceBy(1);
|
||||
|
||||
// Check to see if we have a link/image
|
||||
|
||||
// Inline link?
|
||||
if ($result = $this->tryParseInlineLinkAndTitle($cursor)) {
|
||||
$link = $result;
|
||||
} elseif ($link = $this->tryParseReference($cursor, $inlineContext->getReferenceMap(), $opener, $startPos)) {
|
||||
$reference = $link;
|
||||
$link = ['url' => $link->getDestination(), 'title' => $link->getTitle()];
|
||||
} else {
|
||||
// No match; remove this opener from stack
|
||||
$inlineContext->getDelimiterStack()->removeBracket();
|
||||
$cursor->restoreState($previousState);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$inline = $this->createInline($link['url'], $link['title'], $opener->isImage(), $reference ?? null);
|
||||
$opener->getNode()->replaceWith($inline);
|
||||
while (($label = $inline->next()) !== null) {
|
||||
// Is there a Mention or Link contained within this link?
|
||||
// CommonMark does not allow nested links, so we'll restore the original text.
|
||||
if ($label instanceof Mention) {
|
||||
$label->replaceWith($replacement = new Text($label->getPrefix() . $label->getIdentifier()));
|
||||
$inline->appendChild($replacement);
|
||||
} elseif ($label instanceof Link) {
|
||||
foreach ($label->children() as $child) {
|
||||
$label->insertBefore($child);
|
||||
}
|
||||
|
||||
$label->detach();
|
||||
} else {
|
||||
$inline->appendChild($label);
|
||||
}
|
||||
}
|
||||
|
||||
// Process delimiters such as emphasis inside link/image
|
||||
$delimiterStack = $inlineContext->getDelimiterStack();
|
||||
$stackBottom = $opener->getPosition();
|
||||
$delimiterStack->processDelimiters($stackBottom, $this->environment->getDelimiterProcessors());
|
||||
$delimiterStack->removeBracket();
|
||||
$delimiterStack->removeAll($stackBottom);
|
||||
|
||||
// Merge any adjacent Text nodes together
|
||||
AdjacentTextMerger::mergeChildNodes($inline);
|
||||
|
||||
// processEmphasis will remove this and later delimiters.
|
||||
// Now, for a link, we also remove earlier link openers (no links in links)
|
||||
if (! $opener->isImage()) {
|
||||
$inlineContext->getDelimiterStack()->deactivateLinkOpeners();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function setEnvironment(EnvironmentInterface $environment): void
|
||||
{
|
||||
$this->environment = $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
private function tryParseInlineLinkAndTitle(Cursor $cursor): ?array
|
||||
{
|
||||
if ($cursor->getCurrentCharacter() !== '(') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$previousState = $cursor->saveState();
|
||||
|
||||
$cursor->advanceBy(1);
|
||||
$cursor->advanceToNextNonSpaceOrNewline();
|
||||
if (($dest = LinkParserHelper::parseLinkDestination($cursor)) === null) {
|
||||
$cursor->restoreState($previousState);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$cursor->advanceToNextNonSpaceOrNewline();
|
||||
$previousCharacter = $cursor->peek(-1);
|
||||
// We know from previous lines that we've advanced at least one space so far, so this next call should never be null
|
||||
\assert(\is_string($previousCharacter));
|
||||
|
||||
$title = '';
|
||||
// make sure there's a space before the title:
|
||||
if (\preg_match(RegexHelper::REGEX_WHITESPACE_CHAR, $previousCharacter)) {
|
||||
$title = LinkParserHelper::parseLinkTitle($cursor) ?? '';
|
||||
}
|
||||
|
||||
$cursor->advanceToNextNonSpaceOrNewline();
|
||||
|
||||
if ($cursor->getCurrentCharacter() !== ')') {
|
||||
$cursor->restoreState($previousState);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$cursor->advanceBy(1);
|
||||
|
||||
return ['url' => $dest, 'title' => $title];
|
||||
}
|
||||
|
||||
private function tryParseReference(Cursor $cursor, ReferenceMapInterface $referenceMap, Bracket $opener, int $startPos): ?ReferenceInterface
|
||||
{
|
||||
$savePos = $cursor->saveState();
|
||||
$beforeLabel = $cursor->getPosition();
|
||||
$n = LinkParserHelper::parseLinkLabel($cursor);
|
||||
if ($n > 2) {
|
||||
$start = $beforeLabel + 1;
|
||||
$length = $n - 2;
|
||||
} elseif (! $opener->hasNext()) {
|
||||
// Empty or missing second label means to use the first label as the reference.
|
||||
// The reference must not contain a bracket. If we know there's a bracket, we don't even bother checking it.
|
||||
$start = $opener->getPosition();
|
||||
$length = $startPos - $start;
|
||||
} else {
|
||||
$cursor->restoreState($savePos);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$referenceLabel = $cursor->getSubstring($start, $length);
|
||||
|
||||
if ($n === 0) {
|
||||
// If shortcut reference link, rewind before spaces we skipped
|
||||
$cursor->restoreState($savePos);
|
||||
}
|
||||
|
||||
return $referenceMap->get($referenceLabel);
|
||||
}
|
||||
|
||||
private function createInline(string $url, string $title, bool $isImage, ?ReferenceInterface $reference = null): AbstractWebResource
|
||||
{
|
||||
if ($isImage) {
|
||||
$inline = new Image($url, null, $title);
|
||||
} else {
|
||||
$inline = new Link($url, null, $title);
|
||||
}
|
||||
|
||||
if ($reference) {
|
||||
$inline->data->set('reference', $reference);
|
||||
}
|
||||
|
||||
return $inline;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Parser\Inline;
|
||||
|
||||
use League\CommonMark\Node\Inline\Text;
|
||||
use League\CommonMark\Parser\Inline\InlineParserInterface;
|
||||
use League\CommonMark\Parser\Inline\InlineParserMatch;
|
||||
use League\CommonMark\Parser\InlineParserContext;
|
||||
|
||||
final class OpenBracketParser implements InlineParserInterface
|
||||
{
|
||||
public function getMatchDefinition(): InlineParserMatch
|
||||
{
|
||||
return InlineParserMatch::string('[');
|
||||
}
|
||||
|
||||
public function parse(InlineParserContext $inlineContext): bool
|
||||
{
|
||||
$inlineContext->getCursor()->advanceBy(1);
|
||||
$node = new Text('[', ['delim' => true]);
|
||||
$inlineContext->getContainer()->appendChild($node);
|
||||
|
||||
// Add entry to stack for this opener
|
||||
$inlineContext->getDelimiterStack()->addBracket($node, $inlineContext->getCursor()->getPosition(), false);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\CommonMark\Renderer\Block;
|
||||
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
|
||||
use League\CommonMark\Node\Block\AbstractBlock;
|
||||
use League\CommonMark\Node\Block\Paragraph;
|
||||
use League\CommonMark\Node\Block\TightBlockInterface;
|
||||
use League\CommonMark\Node\Node;
|
||||
use League\CommonMark\Renderer\ChildNodeRendererInterface;
|
||||
use League\CommonMark\Renderer\NodeRendererInterface;
|
||||
use League\CommonMark\Util\HtmlElement;
|
||||
use League\CommonMark\Xml\XmlNodeRendererInterface;
|
||||
|
||||
final class ListItemRenderer implements NodeRendererInterface, XmlNodeRendererInterface
|
||||
{
|
||||
/**
|
||||
* @param ListItem $node
|
||||
*
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @psalm-suppress MoreSpecificImplementedParamType
|
||||
*/
|
||||
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
|
||||
{
|
||||
ListItem::assertInstanceOf($node);
|
||||
|
||||
$contents = $childRenderer->renderNodes($node->children());
|
||||
|
||||
$inTightList = ($parent = $node->parent()) && $parent instanceof TightBlockInterface && $parent->isTight();
|
||||
|
||||
if ($this->needsBlockSeparator($node->firstChild(), $inTightList)) {
|
||||
$contents = "\n" . $contents;
|
||||
}
|
||||
|
||||
if ($this->needsBlockSeparator($node->lastChild(), $inTightList)) {
|
||||
$contents .= "\n";
|
||||
}
|
||||
|
||||
$attrs = $node->data->get('attributes');
|
||||
|
||||
return new HtmlElement('li', $attrs, $contents);
|
||||
}
|
||||
|
||||
public function getXmlTagName(Node $node): string
|
||||
{
|
||||
return 'item';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getXmlAttributes(Node $node): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
private function needsBlockSeparator(?Node $child, bool $inTightList): bool
|
||||
{
|
||||
if ($child instanceof Paragraph && $inTightList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $child instanceof AbstractBlock;
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (http://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\SmartPunct;
|
||||
|
||||
use League\CommonMark\Delimiter\Delimiter;
|
||||
use League\CommonMark\Parser\Inline\InlineParserInterface;
|
||||
use League\CommonMark\Parser\Inline\InlineParserMatch;
|
||||
use League\CommonMark\Parser\InlineParserContext;
|
||||
use League\CommonMark\Util\RegexHelper;
|
||||
|
||||
final class QuoteParser implements InlineParserInterface
|
||||
{
|
||||
/**
|
||||
* @deprecated This constant is no longer used and will be removed in a future major release
|
||||
*/
|
||||
public const DOUBLE_QUOTES = [Quote::DOUBLE_QUOTE, Quote::DOUBLE_QUOTE_OPENER, Quote::DOUBLE_QUOTE_CLOSER];
|
||||
|
||||
/**
|
||||
* @deprecated This constant is no longer used and will be removed in a future major release
|
||||
*/
|
||||
public const SINGLE_QUOTES = [Quote::SINGLE_QUOTE, Quote::SINGLE_QUOTE_OPENER, Quote::SINGLE_QUOTE_CLOSER];
|
||||
|
||||
public function getMatchDefinition(): InlineParserMatch
|
||||
{
|
||||
return InlineParserMatch::oneOf(Quote::SINGLE_QUOTE, Quote::DOUBLE_QUOTE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes any quote characters found and manually adds them to the delimiter stack
|
||||
*/
|
||||
public function parse(InlineParserContext $inlineContext): bool
|
||||
{
|
||||
$char = $inlineContext->getFullMatch();
|
||||
$cursor = $inlineContext->getCursor();
|
||||
$index = $cursor->getPosition();
|
||||
|
||||
$charBefore = $cursor->peek(-1);
|
||||
if ($charBefore === null) {
|
||||
$charBefore = "\n";
|
||||
}
|
||||
|
||||
$cursor->advance();
|
||||
|
||||
$charAfter = $cursor->getCurrentCharacter();
|
||||
if ($charAfter === null) {
|
||||
$charAfter = "\n";
|
||||
}
|
||||
|
||||
[$leftFlanking, $rightFlanking] = $this->determineFlanking($charBefore, $charAfter);
|
||||
$canOpen = $leftFlanking && ! $rightFlanking;
|
||||
$canClose = $rightFlanking;
|
||||
|
||||
$node = new Quote($char, ['delim' => true]);
|
||||
$inlineContext->getContainer()->appendChild($node);
|
||||
|
||||
// Add entry to stack to this opener
|
||||
$inlineContext->getDelimiterStack()->push(new Delimiter($char, 1, $node, $canOpen, $canClose, $index));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool[]
|
||||
*/
|
||||
private function determineFlanking(string $charBefore, string $charAfter): array
|
||||
{
|
||||
$afterIsWhitespace = \preg_match('/\pZ|\s/u', $charAfter);
|
||||
$afterIsPunctuation = \preg_match(RegexHelper::REGEX_PUNCTUATION, $charAfter);
|
||||
$beforeIsWhitespace = \preg_match('/\pZ|\s/u', $charBefore);
|
||||
$beforeIsPunctuation = \preg_match(RegexHelper::REGEX_PUNCTUATION, $charBefore);
|
||||
|
||||
$leftFlanking = ! $afterIsWhitespace &&
|
||||
! ($afterIsPunctuation &&
|
||||
! $beforeIsWhitespace &&
|
||||
! $beforeIsPunctuation);
|
||||
|
||||
$rightFlanking = ! $beforeIsWhitespace &&
|
||||
! ($beforeIsPunctuation &&
|
||||
! $afterIsWhitespace &&
|
||||
! $afterIsPunctuation);
|
||||
|
||||
return [$leftFlanking, $rightFlanking];
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com> and uAfrica.com (http://uafrica.com)
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\Strikethrough;
|
||||
|
||||
use League\CommonMark\Delimiter\DelimiterInterface;
|
||||
use League\CommonMark\Delimiter\Processor\CacheableDelimiterProcessorInterface;
|
||||
use League\CommonMark\Node\Inline\AbstractStringContainer;
|
||||
|
||||
final class StrikethroughDelimiterProcessor implements CacheableDelimiterProcessorInterface
|
||||
{
|
||||
public function getOpeningCharacter(): string
|
||||
{
|
||||
return '~';
|
||||
}
|
||||
|
||||
public function getClosingCharacter(): string
|
||||
{
|
||||
return '~';
|
||||
}
|
||||
|
||||
public function getMinLength(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function getDelimiterUse(DelimiterInterface $opener, DelimiterInterface $closer): int
|
||||
{
|
||||
if ($opener->getLength() > 2 && $closer->getLength() > 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($opener->getLength() !== $closer->getLength()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// $opener and $closer are the same length so we just return one of them
|
||||
return $opener->getLength();
|
||||
}
|
||||
|
||||
public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse): void
|
||||
{
|
||||
$strikethrough = new Strikethrough(\str_repeat('~', $delimiterUse));
|
||||
|
||||
$tmp = $opener->next();
|
||||
while ($tmp !== null && $tmp !== $closer) {
|
||||
$next = $tmp->next();
|
||||
$strikethrough->appendChild($tmp);
|
||||
$tmp = $next;
|
||||
}
|
||||
|
||||
$opener->insertAfter($strikethrough);
|
||||
}
|
||||
|
||||
public function getCacheKey(DelimiterInterface $closer): string
|
||||
{
|
||||
return '~' . $closer->getLength();
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Martin Hasoň <martin.hason@gmail.com>
|
||||
* (c) Webuni s.r.o. <info@webuni.cz>
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\Table;
|
||||
|
||||
use League\CommonMark\Environment\EnvironmentBuilderInterface;
|
||||
use League\CommonMark\Extension\ConfigurableExtensionInterface;
|
||||
use League\CommonMark\Renderer\HtmlDecorator;
|
||||
use League\Config\ConfigurationBuilderInterface;
|
||||
use Nette\Schema\Expect;
|
||||
|
||||
final class TableExtension implements ConfigurableExtensionInterface
|
||||
{
|
||||
public function configureSchema(ConfigurationBuilderInterface $builder): void
|
||||
{
|
||||
$attributeArraySchema = Expect::arrayOf(
|
||||
Expect::type('string|string[]|bool'), // attribute value(s)
|
||||
'string' // attribute name
|
||||
)->mergeDefaults(false);
|
||||
|
||||
$builder->addSchema('table', Expect::structure([
|
||||
'wrap' => Expect::structure([
|
||||
'enabled' => Expect::bool()->default(false),
|
||||
'tag' => Expect::string()->default('div'),
|
||||
'attributes' => Expect::arrayOf(Expect::string()),
|
||||
]),
|
||||
'alignment_attributes' => Expect::structure([
|
||||
'left' => (clone $attributeArraySchema)->default(['align' => 'left']),
|
||||
'center' => (clone $attributeArraySchema)->default(['align' => 'center']),
|
||||
'right' => (clone $attributeArraySchema)->default(['align' => 'right']),
|
||||
]),
|
||||
'max_autocompleted_cells' => Expect::int()->min(0)->default(TableParser::DEFAULT_MAX_AUTOCOMPLETED_CELLS),
|
||||
]));
|
||||
}
|
||||
|
||||
public function register(EnvironmentBuilderInterface $environment): void
|
||||
{
|
||||
$tableRenderer = new TableRenderer();
|
||||
if ($environment->getConfiguration()->get('table/wrap/enabled')) {
|
||||
$tableRenderer = new HtmlDecorator($tableRenderer, $environment->getConfiguration()->get('table/wrap/tag'), $environment->getConfiguration()->get('table/wrap/attributes'));
|
||||
}
|
||||
|
||||
$environment
|
||||
->addBlockStartParser(new TableStartParser($environment->getConfiguration()->get('table/max_autocompleted_cells')))
|
||||
|
||||
->addRenderer(Table::class, $tableRenderer)
|
||||
->addRenderer(TableSection::class, new TableSectionRenderer())
|
||||
->addRenderer(TableRow::class, new TableRowRenderer())
|
||||
->addRenderer(TableCell::class, new TableCellRenderer($environment->getConfiguration()->get('table/alignment_attributes')));
|
||||
}
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Martin Hasoň <martin.hason@gmail.com>
|
||||
* (c) Webuni s.r.o. <info@webuni.cz>
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\Table;
|
||||
|
||||
use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
|
||||
use League\CommonMark\Parser\Block\BlockContinue;
|
||||
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
|
||||
use League\CommonMark\Parser\Block\BlockContinueParserWithInlinesInterface;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Parser\InlineParserEngineInterface;
|
||||
use League\CommonMark\Util\ArrayCollection;
|
||||
|
||||
final class TableParser extends AbstractBlockContinueParser implements BlockContinueParserWithInlinesInterface
|
||||
{
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public const DEFAULT_MAX_AUTOCOMPLETED_CELLS = 10_000;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private Table $block;
|
||||
|
||||
/**
|
||||
* @var ArrayCollection<string>
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private ArrayCollection $bodyLines;
|
||||
|
||||
/**
|
||||
* @var array<int, string|null>
|
||||
* @psalm-var array<int, TableCell::ALIGN_*|null>
|
||||
* @phpstan-var array<int, TableCell::ALIGN_*|null>
|
||||
*
|
||||
* @psalm-readonly
|
||||
*/
|
||||
private array $columns;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private array $headerCells;
|
||||
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private bool $nextIsSeparatorLine = true;
|
||||
|
||||
private int $remainingAutocompletedCells;
|
||||
|
||||
/**
|
||||
* @param array<int, string|null> $columns
|
||||
* @param array<int, string> $headerCells
|
||||
*
|
||||
* @psalm-param array<int, TableCell::ALIGN_*|null> $columns
|
||||
*
|
||||
* @phpstan-param array<int, TableCell::ALIGN_*|null> $columns
|
||||
*/
|
||||
public function __construct(array $columns, array $headerCells, int $remainingAutocompletedCells = self::DEFAULT_MAX_AUTOCOMPLETED_CELLS)
|
||||
{
|
||||
$this->block = new Table();
|
||||
$this->bodyLines = new ArrayCollection();
|
||||
$this->columns = $columns;
|
||||
$this->headerCells = $headerCells;
|
||||
$this->remainingAutocompletedCells = $remainingAutocompletedCells;
|
||||
}
|
||||
|
||||
public function canHaveLazyContinuationLines(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getBlock(): Table
|
||||
{
|
||||
return $this->block;
|
||||
}
|
||||
|
||||
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
|
||||
{
|
||||
if (\strpos($cursor->getLine(), '|') === false) {
|
||||
return BlockContinue::none();
|
||||
}
|
||||
|
||||
return BlockContinue::at($cursor);
|
||||
}
|
||||
|
||||
public function addLine(string $line): void
|
||||
{
|
||||
if ($this->nextIsSeparatorLine) {
|
||||
$this->nextIsSeparatorLine = false;
|
||||
} else {
|
||||
$this->bodyLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
public function parseInlines(InlineParserEngineInterface $inlineParser): void
|
||||
{
|
||||
$headerColumns = \count($this->headerCells);
|
||||
|
||||
$head = new TableSection(TableSection::TYPE_HEAD);
|
||||
$this->block->appendChild($head);
|
||||
|
||||
$headerRow = new TableRow();
|
||||
$head->appendChild($headerRow);
|
||||
for ($i = 0; $i < $headerColumns; $i++) {
|
||||
$cell = $this->headerCells[$i];
|
||||
$tableCell = $this->parseCell($cell, $i, $inlineParser);
|
||||
$tableCell->setType(TableCell::TYPE_HEADER);
|
||||
$headerRow->appendChild($tableCell);
|
||||
}
|
||||
|
||||
$body = null;
|
||||
foreach ($this->bodyLines as $rowLine) {
|
||||
$cells = self::split($rowLine);
|
||||
$row = new TableRow();
|
||||
|
||||
// Body can not have more columns than head
|
||||
for ($i = 0; $i < $headerColumns; $i++) {
|
||||
// It can have less columns though, in which case we'll autocomplete the empty ones (up to some limit)
|
||||
if (! isset($cells[$i]) && $this->remainingAutocompletedCells-- <= 0) {
|
||||
// Too many cells were auto-completed, so we'll just stop here
|
||||
return;
|
||||
}
|
||||
|
||||
$cell = $cells[$i] ?? '';
|
||||
$tableCell = $this->parseCell($cell, $i, $inlineParser);
|
||||
$row->appendChild($tableCell);
|
||||
}
|
||||
|
||||
if ($body === null) {
|
||||
// It's valid to have a table without body. In that case, don't add an empty TableBody node.
|
||||
$body = new TableSection();
|
||||
$this->block->appendChild($body);
|
||||
}
|
||||
|
||||
$body->appendChild($row);
|
||||
}
|
||||
}
|
||||
|
||||
private function parseCell(string $cell, int $column, InlineParserEngineInterface $inlineParser): TableCell
|
||||
{
|
||||
$tableCell = new TableCell(TableCell::TYPE_DATA, $this->columns[$column] ?? null);
|
||||
|
||||
if ($cell !== '') {
|
||||
$inlineParser->parse(\trim($cell), $tableCell);
|
||||
}
|
||||
|
||||
return $tableCell;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function split(string $line): array
|
||||
{
|
||||
$cursor = new Cursor(\trim($line));
|
||||
|
||||
if ($cursor->getCurrentCharacter() === '|') {
|
||||
$cursor->advanceBy(1);
|
||||
}
|
||||
|
||||
$cells = [];
|
||||
$sb = '';
|
||||
|
||||
while (! $cursor->isAtEnd()) {
|
||||
switch ($c = $cursor->getCurrentCharacter()) {
|
||||
case '\\':
|
||||
if ($cursor->peek() === '|') {
|
||||
// Pipe is special for table parsing. An escaped pipe doesn't result in a new cell, but is
|
||||
// passed down to inline parsing as an unescaped pipe. Note that that applies even for the `\|`
|
||||
// in an input like `\\|` - in other words, table parsing doesn't support escaping backslashes.
|
||||
$sb .= '|';
|
||||
$cursor->advanceBy(1);
|
||||
} else {
|
||||
// Preserve backslash before other characters or at end of line.
|
||||
$sb .= '\\';
|
||||
}
|
||||
|
||||
break;
|
||||
case '|':
|
||||
$cells[] = $sb;
|
||||
$sb = '';
|
||||
break;
|
||||
default:
|
||||
$sb .= $c;
|
||||
}
|
||||
|
||||
$cursor->advanceBy(1);
|
||||
}
|
||||
|
||||
if ($sb !== '') {
|
||||
$cells[] = $sb;
|
||||
}
|
||||
|
||||
return $cells;
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Martin Hasoň <martin.hason@gmail.com>
|
||||
* (c) Webuni s.r.o. <info@webuni.cz>
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Extension\Table;
|
||||
|
||||
use League\CommonMark\Parser\Block\BlockStart;
|
||||
use League\CommonMark\Parser\Block\BlockStartParserInterface;
|
||||
use League\CommonMark\Parser\Block\ParagraphParser;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Parser\MarkdownParserStateInterface;
|
||||
|
||||
final class TableStartParser implements BlockStartParserInterface
|
||||
{
|
||||
private int $maxAutocompletedCells;
|
||||
|
||||
public function __construct(int $maxAutocompletedCells = TableParser::DEFAULT_MAX_AUTOCOMPLETED_CELLS)
|
||||
{
|
||||
$this->maxAutocompletedCells = $maxAutocompletedCells;
|
||||
}
|
||||
|
||||
public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserState): ?BlockStart
|
||||
{
|
||||
$paragraph = $parserState->getParagraphContent();
|
||||
if ($paragraph === null || \strpos($paragraph, '|') === false) {
|
||||
return BlockStart::none();
|
||||
}
|
||||
|
||||
$columns = self::parseSeparator($cursor);
|
||||
if (\count($columns) === 0) {
|
||||
return BlockStart::none();
|
||||
}
|
||||
|
||||
$lastLineBreak = \strrpos($paragraph, "\n");
|
||||
$lastLine = $lastLineBreak === false ? $paragraph : \substr($paragraph, $lastLineBreak + 1);
|
||||
|
||||
$headerCells = TableParser::split($lastLine);
|
||||
if (\count($headerCells) > \count($columns)) {
|
||||
return BlockStart::none();
|
||||
}
|
||||
|
||||
$cursor->advanceToEnd();
|
||||
|
||||
$parsers = [];
|
||||
|
||||
if ($lastLineBreak !== false) {
|
||||
$p = new ParagraphParser();
|
||||
$p->addLine(\substr($paragraph, 0, $lastLineBreak));
|
||||
$parsers[] = $p;
|
||||
}
|
||||
|
||||
$parsers[] = new TableParser($columns, $headerCells, $this->maxAutocompletedCells);
|
||||
|
||||
return BlockStart::of(...$parsers)
|
||||
->at($cursor)
|
||||
->replaceActiveBlockParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string|null>
|
||||
*
|
||||
* @psalm-return array<int, TableCell::ALIGN_*|null>
|
||||
*
|
||||
* @phpstan-return array<int, TableCell::ALIGN_*|null>
|
||||
*/
|
||||
private static function parseSeparator(Cursor $cursor): array
|
||||
{
|
||||
$columns = [];
|
||||
$pipes = 0;
|
||||
$valid = false;
|
||||
|
||||
while (! $cursor->isAtEnd()) {
|
||||
switch ($c = $cursor->getCurrentCharacter()) {
|
||||
case '|':
|
||||
$cursor->advanceBy(1);
|
||||
$pipes++;
|
||||
if ($pipes > 1) {
|
||||
// More than one adjacent pipe not allowed
|
||||
return [];
|
||||
}
|
||||
|
||||
// Need at least one pipe, even for a one-column table
|
||||
$valid = true;
|
||||
break;
|
||||
case '-':
|
||||
case ':':
|
||||
if ($pipes === 0 && \count($columns) > 0) {
|
||||
// Need a pipe after the first column (first column doesn't need to start with one)
|
||||
return [];
|
||||
}
|
||||
|
||||
$left = false;
|
||||
$right = false;
|
||||
if ($c === ':') {
|
||||
$left = true;
|
||||
$cursor->advanceBy(1);
|
||||
}
|
||||
|
||||
if ($cursor->match('/^-+/') === null) {
|
||||
// Need at least one dash
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($cursor->getCurrentCharacter() === ':') {
|
||||
$right = true;
|
||||
$cursor->advanceBy(1);
|
||||
}
|
||||
|
||||
$columns[] = self::getAlignment($left, $right);
|
||||
// Next, need another pipe
|
||||
$pipes = 0;
|
||||
break;
|
||||
case ' ':
|
||||
case "\t":
|
||||
// White space is allowed between pipes and columns
|
||||
$cursor->advanceToNextNonSpaceOrTab();
|
||||
break;
|
||||
default:
|
||||
// Any other character is invalid
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (! $valid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-return TableCell::ALIGN_*|null
|
||||
*
|
||||
* @phpstan-return TableCell::ALIGN_*|null
|
||||
*
|
||||
* @psalm-pure
|
||||
*/
|
||||
private static function getAlignment(bool $left, bool $right): ?string
|
||||
{
|
||||
if ($left && $right) {
|
||||
return TableCell::ALIGN_CENTER;
|
||||
}
|
||||
|
||||
if ($left) {
|
||||
return TableCell::ALIGN_LEFT;
|
||||
}
|
||||
|
||||
if ($right) {
|
||||
return TableCell::ALIGN_RIGHT;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Node\Block;
|
||||
|
||||
class Paragraph extends AbstractBlock
|
||||
{
|
||||
/** @internal */
|
||||
public bool $onlyContainsLinkReferenceDefinitions = false;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Normalizer;
|
||||
|
||||
use League\Config\ConfigurationAwareInterface;
|
||||
use League\Config\ConfigurationInterface;
|
||||
|
||||
/**
|
||||
* Creates URL-friendly strings based on the given string input
|
||||
*/
|
||||
final class SlugNormalizer implements TextNormalizerInterface, ConfigurationAwareInterface
|
||||
{
|
||||
/** @psalm-allow-private-mutation */
|
||||
private int $defaultMaxLength = 255;
|
||||
|
||||
public function setConfiguration(ConfigurationInterface $configuration): void
|
||||
{
|
||||
$this->defaultMaxLength = $configuration->get('slug_normalizer/max_length');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @psalm-immutable
|
||||
*/
|
||||
public function normalize(string $text, array $context = []): string
|
||||
{
|
||||
// Add any requested prefix
|
||||
$slug = ($context['prefix'] ?? '') . $text;
|
||||
// Trim whitespace
|
||||
$slug = \trim($slug);
|
||||
// Convert to lowercase
|
||||
$slug = \mb_strtolower($slug, 'UTF-8');
|
||||
// Try replacing whitespace with a dash
|
||||
$slug = \preg_replace('/\s+/u', '-', $slug) ?? $slug;
|
||||
// Try removing characters other than letters, numbers, and marks.
|
||||
$slug = \preg_replace('/[^\p{L}\p{Nd}\p{Nl}\p{M}-]+/u', '', $slug) ?? $slug;
|
||||
// Trim to requested length if given
|
||||
if ($length = $context['length'] ?? $this->defaultMaxLength) {
|
||||
$slug = \mb_substr($slug, 0, $length, 'UTF-8');
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line Because it thinks mb_substr() returns false on PHP 7.4
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace League\CommonMark\Normalizer;
|
||||
|
||||
/***
|
||||
* Normalize text input using the steps given by the CommonMark spec to normalize labels
|
||||
*
|
||||
* @see https://spec.commonmark.org/0.29/#matches
|
||||
*
|
||||
* @psalm-immutable
|
||||
*/
|
||||
final class TextNormalizer implements TextNormalizerInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @psalm-pure
|
||||
*/
|
||||
public function normalize(string $text, array $context = []): string
|
||||
{
|
||||
// Collapse internal whitespace to single space and remove
|
||||
// leading/trailing whitespace
|
||||
$text = \preg_replace('/[ \t\r\n]+/', ' ', \trim($text));
|
||||
\assert(\is_string($text));
|
||||
|
||||
// Is it strictly ASCII? If so, we can use strtolower() instead (faster)
|
||||
if (\mb_check_encoding($text, 'ASCII')) {
|
||||
return \strtolower($text);
|
||||
}
|
||||
|
||||
return \mb_convert_case($text, \MB_CASE_FOLD, 'UTF-8');
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Parser\Block;
|
||||
|
||||
use League\CommonMark\Node\Block\AbstractBlock;
|
||||
use League\CommonMark\Node\Block\Document;
|
||||
use League\CommonMark\Node\Block\Paragraph;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Reference\ReferenceMapInterface;
|
||||
|
||||
/**
|
||||
* Parser implementation which ensures everything is added to the root-level Document
|
||||
*/
|
||||
final class DocumentBlockParser extends AbstractBlockContinueParser
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private Document $document;
|
||||
|
||||
public function __construct(ReferenceMapInterface $referenceMap)
|
||||
{
|
||||
$this->document = new Document($referenceMap);
|
||||
}
|
||||
|
||||
public function getBlock(): Document
|
||||
{
|
||||
return $this->document;
|
||||
}
|
||||
|
||||
public function isContainer(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function canContain(AbstractBlock $childBlock): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
|
||||
{
|
||||
return BlockContinue::at($cursor);
|
||||
}
|
||||
|
||||
public function closeBlock(): void
|
||||
{
|
||||
$this->removeLinkReferenceDefinitions();
|
||||
}
|
||||
|
||||
private function removeLinkReferenceDefinitions(): void
|
||||
{
|
||||
$emptyNodes = [];
|
||||
|
||||
$walker = $this->document->walker();
|
||||
while ($event = $walker->next()) {
|
||||
$node = $event->getNode();
|
||||
// TODO for v3: It would be great if we could find an alternate way to identify such paragraphs.
|
||||
// Unfortunately, we can't simply check for empty paragraphs here because inlines haven't been processed yet,
|
||||
// meaning all paragraphs will appear blank here, and we don't have a way to check the status of the reference parser
|
||||
// which is attached to the (already-closed) paragraph parser.
|
||||
if ($event->isEntering() && $node instanceof Paragraph && $node->onlyContainsLinkReferenceDefinitions) {
|
||||
$emptyNodes[] = $node;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($emptyNodes as $node) {
|
||||
$node->detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Parser\Block;
|
||||
|
||||
use League\CommonMark\Node\Block\Paragraph;
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
use League\CommonMark\Parser\InlineParserEngineInterface;
|
||||
use League\CommonMark\Reference\ReferenceInterface;
|
||||
use League\CommonMark\Reference\ReferenceParser;
|
||||
|
||||
final class ParagraphParser extends AbstractBlockContinueParser implements BlockContinueParserWithInlinesInterface
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private Paragraph $block;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private ReferenceParser $referenceParser;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->block = new Paragraph();
|
||||
$this->referenceParser = new ReferenceParser();
|
||||
}
|
||||
|
||||
public function canHaveLazyContinuationLines(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getBlock(): Paragraph
|
||||
{
|
||||
return $this->block;
|
||||
}
|
||||
|
||||
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
|
||||
{
|
||||
if ($cursor->isBlank()) {
|
||||
return BlockContinue::none();
|
||||
}
|
||||
|
||||
return BlockContinue::at($cursor);
|
||||
}
|
||||
|
||||
public function addLine(string $line): void
|
||||
{
|
||||
$this->referenceParser->parse($line);
|
||||
}
|
||||
|
||||
public function closeBlock(): void
|
||||
{
|
||||
$this->block->onlyContainsLinkReferenceDefinitions = $this->referenceParser->hasReferences() && $this->referenceParser->getParagraphContent() === '';
|
||||
}
|
||||
|
||||
public function parseInlines(InlineParserEngineInterface $inlineParser): void
|
||||
{
|
||||
$content = $this->getContentString();
|
||||
if ($content !== '') {
|
||||
$inlineParser->parse($content, $this->block);
|
||||
}
|
||||
}
|
||||
|
||||
public function getContentString(): string
|
||||
{
|
||||
return $this->referenceParser->getParagraphContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ReferenceInterface[]
|
||||
*/
|
||||
public function getReferences(): iterable
|
||||
{
|
||||
return $this->referenceParser->getReferences();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Parser;
|
||||
|
||||
use League\CommonMark\Exception\UnexpectedEncodingException;
|
||||
|
||||
class Cursor
|
||||
{
|
||||
public const INDENT_LEVEL = 4;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private string $line;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private int $length;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* It's possible for this to be 1 char past the end, meaning we've parsed all chars and have
|
||||
* reached the end. In this state, any character-returning method MUST return null.
|
||||
*/
|
||||
private int $currentPosition = 0;
|
||||
|
||||
private int $column = 0;
|
||||
|
||||
private int $indent = 0;
|
||||
|
||||
private int $previousPosition = 0;
|
||||
|
||||
private ?int $nextNonSpaceCache = null;
|
||||
|
||||
private bool $partiallyConsumedTab = false;
|
||||
|
||||
/**
|
||||
* @var int|false
|
||||
*
|
||||
* @psalm-readonly
|
||||
*/
|
||||
private $lastTabPosition;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private bool $isMultibyte;
|
||||
|
||||
/** @var array<int, string> */
|
||||
private array $charCache = [];
|
||||
|
||||
/**
|
||||
* @param string $line The line being parsed (ASCII or UTF-8)
|
||||
*/
|
||||
public function __construct(string $line)
|
||||
{
|
||||
if (! \mb_check_encoding($line, 'UTF-8')) {
|
||||
throw new UnexpectedEncodingException('Unexpected encoding - UTF-8 or ASCII was expected');
|
||||
}
|
||||
|
||||
$this->line = $line;
|
||||
$this->length = \mb_strlen($line, 'UTF-8') ?: 0;
|
||||
$this->isMultibyte = $this->length !== \strlen($line);
|
||||
$this->lastTabPosition = $this->isMultibyte ? \mb_strrpos($line, "\t", 0, 'UTF-8') : \strrpos($line, "\t");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position of the next character which is not a space (or tab)
|
||||
*/
|
||||
public function getNextNonSpacePosition(): int
|
||||
{
|
||||
if ($this->nextNonSpaceCache !== null) {
|
||||
return $this->nextNonSpaceCache;
|
||||
}
|
||||
|
||||
if ($this->currentPosition >= $this->length) {
|
||||
return $this->length;
|
||||
}
|
||||
|
||||
$cols = $this->column;
|
||||
|
||||
for ($i = $this->currentPosition; $i < $this->length; $i++) {
|
||||
// This if-else was copied out of getCharacter() for performance reasons
|
||||
if ($this->isMultibyte) {
|
||||
$c = $this->charCache[$i] ??= \mb_substr($this->line, $i, 1, 'UTF-8');
|
||||
} else {
|
||||
$c = $this->line[$i];
|
||||
}
|
||||
|
||||
if ($c === ' ') {
|
||||
$cols++;
|
||||
} elseif ($c === "\t") {
|
||||
$cols += 4 - ($cols % 4);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->indent = $cols - $this->column;
|
||||
|
||||
return $this->nextNonSpaceCache = $i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next character which isn't a space (or tab)
|
||||
*/
|
||||
public function getNextNonSpaceCharacter(): ?string
|
||||
{
|
||||
$index = $this->getNextNonSpacePosition();
|
||||
if ($index >= $this->length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isMultibyte) {
|
||||
return $this->charCache[$index] ??= \mb_substr($this->line, $index, 1, 'UTF-8');
|
||||
}
|
||||
|
||||
return $this->line[$index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the current indent (number of spaces after current position)
|
||||
*/
|
||||
public function getIndent(): int
|
||||
{
|
||||
if ($this->nextNonSpaceCache === null) {
|
||||
$this->getNextNonSpacePosition();
|
||||
}
|
||||
|
||||
return $this->indent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cursor is indented to INDENT_LEVEL
|
||||
*/
|
||||
public function isIndented(): bool
|
||||
{
|
||||
if ($this->nextNonSpaceCache === null) {
|
||||
$this->getNextNonSpacePosition();
|
||||
}
|
||||
|
||||
return $this->indent >= self::INDENT_LEVEL;
|
||||
}
|
||||
|
||||
public function getCharacter(?int $index = null): ?string
|
||||
{
|
||||
if ($index === null) {
|
||||
$index = $this->currentPosition;
|
||||
}
|
||||
|
||||
// Index out-of-bounds, or we're at the end
|
||||
if ($index < 0 || $index >= $this->length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isMultibyte) {
|
||||
return $this->charCache[$index] ??= \mb_substr($this->line, $index, 1, 'UTF-8');
|
||||
}
|
||||
|
||||
return $this->line[$index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Slightly-optimized version of getCurrent(null)
|
||||
*/
|
||||
public function getCurrentCharacter(): ?string
|
||||
{
|
||||
if ($this->currentPosition >= $this->length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->isMultibyte) {
|
||||
return $this->charCache[$this->currentPosition] ??= \mb_substr($this->line, $this->currentPosition, 1, 'UTF-8');
|
||||
}
|
||||
|
||||
return $this->line[$this->currentPosition];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next character (or null, if none) without advancing forwards
|
||||
*/
|
||||
public function peek(int $offset = 1): ?string
|
||||
{
|
||||
return $this->getCharacter($this->currentPosition + $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the remainder is blank
|
||||
*/
|
||||
public function isBlank(): bool
|
||||
{
|
||||
return $this->nextNonSpaceCache === $this->length || $this->getNextNonSpacePosition() === $this->length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the cursor forwards
|
||||
*/
|
||||
public function advance(): void
|
||||
{
|
||||
$this->advanceBy(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the cursor forwards
|
||||
*
|
||||
* @param int $characters Number of characters to advance by
|
||||
* @param bool $advanceByColumns Whether to advance by columns instead of spaces
|
||||
*/
|
||||
public function advanceBy(int $characters, bool $advanceByColumns = false): void
|
||||
{
|
||||
$this->previousPosition = $this->currentPosition;
|
||||
$this->nextNonSpaceCache = null;
|
||||
|
||||
if ($this->currentPosition >= $this->length || $characters === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimization to avoid tab handling logic if we have no tabs
|
||||
if ($this->lastTabPosition === false || $this->currentPosition > $this->lastTabPosition) {
|
||||
$length = \min($characters, $this->length - $this->currentPosition);
|
||||
$this->partiallyConsumedTab = false;
|
||||
$this->currentPosition += $length;
|
||||
$this->column += $length;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$nextFewChars = $this->isMultibyte ?
|
||||
\mb_substr($this->line, $this->currentPosition, $characters, 'UTF-8') :
|
||||
\substr($this->line, $this->currentPosition, $characters);
|
||||
|
||||
if ($characters === 1) {
|
||||
$asArray = [$nextFewChars];
|
||||
} elseif ($this->isMultibyte) {
|
||||
/** @var string[] $asArray */
|
||||
$asArray = \mb_str_split($nextFewChars, 1, 'UTF-8');
|
||||
} else {
|
||||
$asArray = \str_split($nextFewChars);
|
||||
}
|
||||
|
||||
foreach ($asArray as $c) {
|
||||
if ($c === "\t") {
|
||||
$charsToTab = 4 - ($this->column % 4);
|
||||
if ($advanceByColumns) {
|
||||
$this->partiallyConsumedTab = $charsToTab > $characters;
|
||||
$charsToAdvance = $charsToTab > $characters ? $characters : $charsToTab;
|
||||
$this->column += $charsToAdvance;
|
||||
$this->currentPosition += $this->partiallyConsumedTab ? 0 : 1;
|
||||
$characters -= $charsToAdvance;
|
||||
} else {
|
||||
$this->partiallyConsumedTab = false;
|
||||
$this->column += $charsToTab;
|
||||
$this->currentPosition++;
|
||||
$characters--;
|
||||
}
|
||||
} else {
|
||||
$this->partiallyConsumedTab = false;
|
||||
$this->currentPosition++;
|
||||
$this->column++;
|
||||
$characters--;
|
||||
}
|
||||
|
||||
if ($characters <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the cursor by a single space or tab, if present
|
||||
*/
|
||||
public function advanceBySpaceOrTab(): bool
|
||||
{
|
||||
$character = $this->getCurrentCharacter();
|
||||
|
||||
if ($character === ' ' || $character === "\t") {
|
||||
$this->advanceBy(1, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse zero or more space/tab characters
|
||||
*
|
||||
* @return int Number of positions moved
|
||||
*/
|
||||
public function advanceToNextNonSpaceOrTab(): int
|
||||
{
|
||||
$newPosition = $this->nextNonSpaceCache ?? $this->getNextNonSpacePosition();
|
||||
if ($newPosition === $this->currentPosition) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->advanceBy($newPosition - $this->currentPosition);
|
||||
$this->partiallyConsumedTab = false;
|
||||
|
||||
// We've just advanced to where that non-space is,
|
||||
// so any subsequent calls to find the next one will
|
||||
// always return the current position.
|
||||
$this->nextNonSpaceCache = $this->currentPosition;
|
||||
$this->indent = 0;
|
||||
|
||||
return $this->currentPosition - $this->previousPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse zero or more space characters, including at most one newline.
|
||||
*
|
||||
* Tab characters are not parsed with this function.
|
||||
*
|
||||
* @return int Number of positions moved
|
||||
*/
|
||||
public function advanceToNextNonSpaceOrNewline(): int
|
||||
{
|
||||
$currentCharacter = $this->getCurrentCharacter();
|
||||
|
||||
// Optimization: Avoid the regex if we know there are no spaces or newlines
|
||||
if ($currentCharacter !== ' ' && $currentCharacter !== "\n") {
|
||||
$this->previousPosition = $this->currentPosition;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
\preg_match('/^ *(?:\n *)?/', $this->getRemainder(), $matches, \PREG_OFFSET_CAPTURE);
|
||||
|
||||
// [0][0] contains the matched text
|
||||
// [0][1] contains the index of that match
|
||||
\assert(isset($matches[0]));
|
||||
$increment = $matches[0][1] + \strlen($matches[0][0]);
|
||||
|
||||
$this->advanceBy($increment);
|
||||
|
||||
return $this->currentPosition - $this->previousPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the position to the very end of the line
|
||||
*
|
||||
* @return int The number of characters moved
|
||||
*/
|
||||
public function advanceToEnd(): int
|
||||
{
|
||||
$this->previousPosition = $this->currentPosition;
|
||||
$this->nextNonSpaceCache = null;
|
||||
|
||||
$this->currentPosition = $this->length;
|
||||
|
||||
return $this->currentPosition - $this->previousPosition;
|
||||
}
|
||||
|
||||
public function getRemainder(): string
|
||||
{
|
||||
if ($this->currentPosition >= $this->length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$prefix = '';
|
||||
$position = $this->currentPosition;
|
||||
if ($this->partiallyConsumedTab) {
|
||||
$position++;
|
||||
$charsToTab = 4 - ($this->column % 4);
|
||||
$prefix = \str_repeat(' ', $charsToTab);
|
||||
}
|
||||
|
||||
$subString = $this->isMultibyte ?
|
||||
\mb_substr($this->line, $position, null, 'UTF-8') :
|
||||
\substr($this->line, $position);
|
||||
|
||||
return $prefix . $subString;
|
||||
}
|
||||
|
||||
public function getLine(): string
|
||||
{
|
||||
return $this->line;
|
||||
}
|
||||
|
||||
public function isAtEnd(): bool
|
||||
{
|
||||
return $this->currentPosition >= $this->length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to match a regular expression
|
||||
*
|
||||
* Returns the matching text and advances to the end of that match
|
||||
*
|
||||
* @psalm-param non-empty-string $regex
|
||||
*/
|
||||
public function match(string $regex): ?string
|
||||
{
|
||||
$subject = $this->getRemainder();
|
||||
|
||||
if (! \preg_match($regex, $subject, $matches, \PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// $matches[0][0] contains the matched text
|
||||
// $matches[0][1] contains the index of that match
|
||||
|
||||
if ($this->isMultibyte) {
|
||||
// PREG_OFFSET_CAPTURE always returns the byte offset, not the char offset, which is annoying
|
||||
$offset = \mb_strlen(\substr($subject, 0, $matches[0][1]), 'UTF-8');
|
||||
$matchLength = \mb_strlen($matches[0][0], 'UTF-8');
|
||||
} else {
|
||||
$offset = $matches[0][1];
|
||||
$matchLength = \strlen($matches[0][0]);
|
||||
}
|
||||
|
||||
// [0][0] contains the matched text
|
||||
// [0][1] contains the index of that match
|
||||
$this->advanceBy($offset + $matchLength);
|
||||
|
||||
return $matches[0][0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates the current state of this cursor in case you need to rollback later.
|
||||
*
|
||||
* WARNING: Do not parse or use the return value for ANYTHING except for
|
||||
* passing it back into restoreState(), as the number of values and their
|
||||
* contents may change in any future release without warning.
|
||||
*/
|
||||
public function saveState(): CursorState
|
||||
{
|
||||
return new CursorState([
|
||||
$this->currentPosition,
|
||||
$this->previousPosition,
|
||||
$this->nextNonSpaceCache,
|
||||
$this->indent,
|
||||
$this->column,
|
||||
$this->partiallyConsumedTab,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the cursor to a previous state.
|
||||
*
|
||||
* Pass in the value previously obtained by calling saveState().
|
||||
*/
|
||||
public function restoreState(CursorState $state): void
|
||||
{
|
||||
[
|
||||
$this->currentPosition,
|
||||
$this->previousPosition,
|
||||
$this->nextNonSpaceCache,
|
||||
$this->indent,
|
||||
$this->column,
|
||||
$this->partiallyConsumedTab,
|
||||
] = $state->toArray();
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->currentPosition;
|
||||
}
|
||||
|
||||
public function getPreviousText(): string
|
||||
{
|
||||
if ($this->isMultibyte) {
|
||||
return \mb_substr($this->line, $this->previousPosition, $this->currentPosition - $this->previousPosition, 'UTF-8');
|
||||
}
|
||||
|
||||
return \substr($this->line, $this->previousPosition, $this->currentPosition - $this->previousPosition);
|
||||
}
|
||||
|
||||
public function getSubstring(int $start, ?int $length = null): string
|
||||
{
|
||||
if ($this->isMultibyte) {
|
||||
return \mb_substr($this->line, $start, $length, 'UTF-8');
|
||||
}
|
||||
|
||||
if ($length !== null) {
|
||||
return \substr($this->line, $start, $length);
|
||||
}
|
||||
|
||||
return \substr($this->line, $start);
|
||||
}
|
||||
|
||||
public function getColumn(): int
|
||||
{
|
||||
return $this->column;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Parser;
|
||||
|
||||
use League\CommonMark\Delimiter\DelimiterStack;
|
||||
use League\CommonMark\Node\Block\AbstractBlock;
|
||||
use League\CommonMark\Reference\ReferenceMapInterface;
|
||||
|
||||
final class InlineParserContext
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private AbstractBlock $container;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private ReferenceMapInterface $referenceMap;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private Cursor $cursor;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private DelimiterStack $delimiterStack;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
* @psalm-var non-empty-array<string>
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private array $matches;
|
||||
|
||||
public function __construct(Cursor $contents, AbstractBlock $container, ReferenceMapInterface $referenceMap, int $maxDelimitersPerLine = PHP_INT_MAX)
|
||||
{
|
||||
$this->referenceMap = $referenceMap;
|
||||
$this->container = $container;
|
||||
$this->cursor = $contents;
|
||||
$this->delimiterStack = new DelimiterStack($maxDelimitersPerLine);
|
||||
}
|
||||
|
||||
public function getContainer(): AbstractBlock
|
||||
{
|
||||
return $this->container;
|
||||
}
|
||||
|
||||
public function getReferenceMap(): ReferenceMapInterface
|
||||
{
|
||||
return $this->referenceMap;
|
||||
}
|
||||
|
||||
public function getCursor(): Cursor
|
||||
{
|
||||
return $this->cursor;
|
||||
}
|
||||
|
||||
public function getDelimiterStack(): DelimiterStack
|
||||
{
|
||||
return $this->delimiterStack;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The full text that matched the InlineParserMatch definition
|
||||
*/
|
||||
public function getFullMatch(): string
|
||||
{
|
||||
return $this->matches[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int The length of the full match (in characters, not bytes)
|
||||
*/
|
||||
public function getFullMatchLength(): int
|
||||
{
|
||||
return \mb_strlen($this->matches[0], 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] Similar to preg_match(), index 0 will contain the full match, and any other array elements will be captured sub-matches
|
||||
*
|
||||
* @psalm-return non-empty-array<string>
|
||||
*/
|
||||
public function getMatches(): array
|
||||
{
|
||||
return $this->matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getSubMatches(): array
|
||||
{
|
||||
return \array_slice($this->matches, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $matches
|
||||
*
|
||||
* @psalm-param non-empty-array<string> $matches
|
||||
*/
|
||||
public function withMatches(array $matches): InlineParserContext
|
||||
{
|
||||
$ctx = clone $this;
|
||||
|
||||
$ctx->matches = $matches;
|
||||
|
||||
return $ctx;
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Parser;
|
||||
|
||||
use League\CommonMark\Environment\EnvironmentInterface;
|
||||
use League\CommonMark\Node\Block\AbstractBlock;
|
||||
use League\CommonMark\Node\Inline\AdjacentTextMerger;
|
||||
use League\CommonMark\Node\Inline\Text;
|
||||
use League\CommonMark\Parser\Inline\InlineParserInterface;
|
||||
use League\CommonMark\Reference\ReferenceMapInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InlineParserEngine implements InlineParserEngineInterface
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private EnvironmentInterface $environment;
|
||||
|
||||
/** @psalm-readonly */
|
||||
private ReferenceMapInterface $referenceMap;
|
||||
|
||||
/**
|
||||
* @var array<int, InlineParserInterface|string|bool>
|
||||
* @psalm-var list<array{0: InlineParserInterface, 1: non-empty-string, 2: bool}>
|
||||
* @phpstan-var array<int, array{0: InlineParserInterface, 1: non-empty-string, 2: bool}>
|
||||
*/
|
||||
private array $parsers = [];
|
||||
|
||||
public function __construct(EnvironmentInterface $environment, ReferenceMapInterface $referenceMap)
|
||||
{
|
||||
$this->environment = $environment;
|
||||
$this->referenceMap = $referenceMap;
|
||||
|
||||
foreach ($environment->getInlineParsers() as $parser) {
|
||||
\assert($parser instanceof InlineParserInterface);
|
||||
$regex = $parser->getMatchDefinition()->getRegex();
|
||||
|
||||
$this->parsers[] = [$parser, $regex, \strlen($regex) !== \mb_strlen($regex, 'UTF-8')];
|
||||
}
|
||||
}
|
||||
|
||||
public function parse(string $contents, AbstractBlock $block): void
|
||||
{
|
||||
$contents = \trim($contents);
|
||||
$cursor = new Cursor($contents);
|
||||
|
||||
$inlineParserContext = new InlineParserContext($cursor, $block, $this->referenceMap, $this->environment->getConfiguration()->get('max_delimiters_per_line'));
|
||||
|
||||
// Have all parsers look at the line to determine what they might want to parse and what positions they exist at
|
||||
foreach ($this->matchParsers($contents) as $matchPosition => $parsers) {
|
||||
$currentPosition = $cursor->getPosition();
|
||||
// We've already gone past this point
|
||||
if ($currentPosition > $matchPosition) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// We've skipped over some uninteresting text that should be added as a plain text node
|
||||
if ($currentPosition < $matchPosition) {
|
||||
$cursor->advanceBy($matchPosition - $currentPosition);
|
||||
$this->addPlainText($cursor->getPreviousText(), $block);
|
||||
}
|
||||
|
||||
// We're now at a potential start - see which of the current parsers can handle it
|
||||
$parsed = false;
|
||||
foreach ($parsers as [$parser, $matches]) {
|
||||
\assert($parser instanceof InlineParserInterface);
|
||||
if ($parser->parse($inlineParserContext->withMatches($matches))) {
|
||||
// A parser has successfully handled the text at the given position; don't consider any others at this position
|
||||
$parsed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Despite potentially being interested, nothing actually parsed text here, so add the current character and continue onwards
|
||||
$this->addPlainText((string) $cursor->getCurrentCharacter(), $block);
|
||||
$cursor->advance();
|
||||
}
|
||||
|
||||
// Add any remaining text that wasn't parsed
|
||||
if (! $cursor->isAtEnd()) {
|
||||
$this->addPlainText($cursor->getRemainder(), $block);
|
||||
}
|
||||
|
||||
// Process any delimiters that were found
|
||||
$delimiterStack = $inlineParserContext->getDelimiterStack();
|
||||
$delimiterStack->processDelimiters(null, $this->environment->getDelimiterProcessors());
|
||||
$delimiterStack->removeAll();
|
||||
|
||||
// Combine adjacent text notes into one
|
||||
AdjacentTextMerger::mergeChildNodes($block);
|
||||
}
|
||||
|
||||
private function addPlainText(string $text, AbstractBlock $container): void
|
||||
{
|
||||
$lastInline = $container->lastChild();
|
||||
if ($lastInline instanceof Text && ! $lastInline->data->has('delim')) {
|
||||
$lastInline->append($text);
|
||||
} else {
|
||||
$container->appendChild(new Text($text));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the current line, ask all the parsers which parts of the text they would be interested in parsing.
|
||||
*
|
||||
* The resulting array provides a list of character positions, which parsers are interested in trying to parse
|
||||
* the text at those points, and (for convenience/optimization) what the matching text happened to be.
|
||||
*
|
||||
* @return array<array<int, InlineParserInterface|string>>
|
||||
*
|
||||
* @psalm-return array<int, list<array{0: InlineParserInterface, 1: non-empty-array<string>}>>
|
||||
*
|
||||
* @phpstan-return array<int, array<int, array{0: InlineParserInterface, 1: non-empty-array<string>}>>
|
||||
*/
|
||||
private function matchParsers(string $contents): array
|
||||
{
|
||||
$contents = \trim($contents);
|
||||
$isMultibyte = ! \mb_check_encoding($contents, 'ASCII');
|
||||
|
||||
$ret = [];
|
||||
|
||||
foreach ($this->parsers as [$parser, $regex, $isRegexMultibyte]) {
|
||||
if ($isMultibyte || $isRegexMultibyte) {
|
||||
$regex .= 'u';
|
||||
}
|
||||
|
||||
// See if the parser's InlineParserMatch regex matched against any part of the string
|
||||
if (! \preg_match_all($regex, $contents, $matches, \PREG_OFFSET_CAPTURE | \PREG_SET_ORDER)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For each part that matched...
|
||||
foreach ($matches as $match) {
|
||||
if ($isMultibyte) {
|
||||
// PREG_OFFSET_CAPTURE always returns the byte offset, not the char offset, which is annoying
|
||||
$offset = \mb_strlen(\substr($contents, 0, $match[0][1]), 'UTF-8');
|
||||
} else {
|
||||
$offset = \intval($match[0][1]);
|
||||
}
|
||||
|
||||
// Remove the offsets, keeping only the matched text
|
||||
$m = \array_column($match, 0);
|
||||
|
||||
if ($m === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add this match to the list of character positions to stop at
|
||||
$ret[$offset][] = [$parser, $m];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort matches by position so we visit them in order
|
||||
\ksort($ret);
|
||||
|
||||
return $ret;
|
||||
}
|
||||
}
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* Additional code based on commonmark-java (https://github.com/commonmark/commonmark-java)
|
||||
* - (c) Atlassian Pty Ltd
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Parser;
|
||||
|
||||
use League\CommonMark\Environment\EnvironmentInterface;
|
||||
use League\CommonMark\Event\DocumentParsedEvent;
|
||||
use League\CommonMark\Event\DocumentPreParsedEvent;
|
||||
use League\CommonMark\Exception\CommonMarkException;
|
||||
use League\CommonMark\Input\MarkdownInput;
|
||||
use League\CommonMark\Node\Block\Document;
|
||||
use League\CommonMark\Node\Block\Paragraph;
|
||||
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
|
||||
use League\CommonMark\Parser\Block\BlockContinueParserWithInlinesInterface;
|
||||
use League\CommonMark\Parser\Block\BlockStart;
|
||||
use League\CommonMark\Parser\Block\BlockStartParserInterface;
|
||||
use League\CommonMark\Parser\Block\DocumentBlockParser;
|
||||
use League\CommonMark\Parser\Block\ParagraphParser;
|
||||
use League\CommonMark\Reference\MemoryLimitedReferenceMap;
|
||||
use League\CommonMark\Reference\ReferenceInterface;
|
||||
use League\CommonMark\Reference\ReferenceMap;
|
||||
|
||||
final class MarkdownParser implements MarkdownParserInterface
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private EnvironmentInterface $environment;
|
||||
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private int $maxNestingLevel;
|
||||
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private ReferenceMap $referenceMap;
|
||||
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private int $lineNumber = 0;
|
||||
|
||||
/** @psalm-readonly-allow-private-mutation */
|
||||
private Cursor $cursor;
|
||||
|
||||
/**
|
||||
* @var array<int, BlockContinueParserInterface>
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private array $activeBlockParsers = [];
|
||||
|
||||
/**
|
||||
* @var array<int, BlockContinueParserWithInlinesInterface>
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private array $closedBlockParsers = [];
|
||||
|
||||
public function __construct(EnvironmentInterface $environment)
|
||||
{
|
||||
$this->environment = $environment;
|
||||
}
|
||||
|
||||
private function initialize(): void
|
||||
{
|
||||
$this->referenceMap = new ReferenceMap();
|
||||
$this->lineNumber = 0;
|
||||
$this->activeBlockParsers = [];
|
||||
$this->closedBlockParsers = [];
|
||||
|
||||
$this->maxNestingLevel = $this->environment->getConfiguration()->get('max_nesting_level');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CommonMarkException
|
||||
*/
|
||||
public function parse(string $input): Document
|
||||
{
|
||||
$this->initialize();
|
||||
|
||||
$documentParser = new DocumentBlockParser($this->referenceMap);
|
||||
$this->activateBlockParser($documentParser);
|
||||
|
||||
$preParsedEvent = new DocumentPreParsedEvent($documentParser->getBlock(), new MarkdownInput($input));
|
||||
$this->environment->dispatch($preParsedEvent);
|
||||
$markdownInput = $preParsedEvent->getMarkdown();
|
||||
|
||||
foreach ($markdownInput->getLines() as $lineNumber => $line) {
|
||||
$this->lineNumber = $lineNumber;
|
||||
$this->parseLine($line);
|
||||
}
|
||||
|
||||
// finalizeAndProcess
|
||||
$this->closeBlockParsers(\count($this->activeBlockParsers), $this->lineNumber);
|
||||
$this->processInlines(\strlen($input));
|
||||
|
||||
$this->environment->dispatch(new DocumentParsedEvent($documentParser->getBlock()));
|
||||
|
||||
return $documentParser->getBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a line of text and update the document appropriately. We parse markdown text by calling this on each
|
||||
* line of input, then finalizing the document.
|
||||
*/
|
||||
private function parseLine(string $line): void
|
||||
{
|
||||
// replace NUL characters for security
|
||||
$line = \str_replace("\0", "\u{FFFD}", $line);
|
||||
|
||||
$this->cursor = new Cursor($line);
|
||||
|
||||
$matches = $this->parseBlockContinuation();
|
||||
if ($matches === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$unmatchedBlocks = \count($this->activeBlockParsers) - $matches;
|
||||
$blockParser = $this->activeBlockParsers[$matches - 1];
|
||||
$startedNewBlock = false;
|
||||
|
||||
// Unless last matched container is a code block, try new container starts,
|
||||
// adding children to the last matched container:
|
||||
$tryBlockStarts = $blockParser->getBlock() instanceof Paragraph || $blockParser->isContainer();
|
||||
while ($tryBlockStarts) {
|
||||
// this is a little performance optimization
|
||||
if ($this->cursor->isBlank()) {
|
||||
$this->cursor->advanceToEnd();
|
||||
break;
|
||||
}
|
||||
|
||||
if ($blockParser->getBlock()->getDepth() >= $this->maxNestingLevel) {
|
||||
break;
|
||||
}
|
||||
|
||||
$blockStart = $this->findBlockStart($blockParser);
|
||||
if ($blockStart === null || $blockStart->isAborting()) {
|
||||
$this->cursor->advanceToNextNonSpaceOrTab();
|
||||
break;
|
||||
}
|
||||
|
||||
if (($state = $blockStart->getCursorState()) !== null) {
|
||||
$this->cursor->restoreState($state);
|
||||
}
|
||||
|
||||
$startedNewBlock = true;
|
||||
|
||||
// We're starting a new block. If we have any previous blocks that need to be closed, we need to do it now.
|
||||
if ($unmatchedBlocks > 0) {
|
||||
$this->closeBlockParsers($unmatchedBlocks, $this->lineNumber - 1);
|
||||
$unmatchedBlocks = 0;
|
||||
}
|
||||
|
||||
$oldBlockLineStart = null;
|
||||
if ($blockStart->isReplaceActiveBlockParser()) {
|
||||
$oldBlockLineStart = $this->prepareActiveBlockParserForReplacement();
|
||||
}
|
||||
|
||||
foreach ($blockStart->getBlockParsers() as $newBlockParser) {
|
||||
$blockParser = $this->addChild($newBlockParser, $oldBlockLineStart);
|
||||
$tryBlockStarts = $newBlockParser->isContainer();
|
||||
}
|
||||
}
|
||||
|
||||
// What remains at the offset is a text line. Add the text to the appropriate block.
|
||||
|
||||
// First check for a lazy paragraph continuation:
|
||||
if (! $startedNewBlock && ! $this->cursor->isBlank() && $this->getActiveBlockParser()->canHaveLazyContinuationLines()) {
|
||||
$this->getActiveBlockParser()->addLine($this->cursor->getRemainder());
|
||||
} else {
|
||||
// finalize any blocks not matched
|
||||
if ($unmatchedBlocks > 0) {
|
||||
$this->closeBlockParsers($unmatchedBlocks, $this->lineNumber - 1);
|
||||
}
|
||||
|
||||
if (! $blockParser->isContainer()) {
|
||||
$this->getActiveBlockParser()->addLine($this->cursor->getRemainder());
|
||||
} elseif (! $this->cursor->isBlank()) {
|
||||
$this->addChild(new ParagraphParser());
|
||||
$this->getActiveBlockParser()->addLine($this->cursor->getRemainder());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function parseBlockContinuation(): ?int
|
||||
{
|
||||
// For each containing block, try to parse the associated line start.
|
||||
// The document will always match, so we can skip the first block parser and start at 1 matches
|
||||
$matches = 1;
|
||||
for ($i = 1; $i < \count($this->activeBlockParsers); $i++) {
|
||||
$blockParser = $this->activeBlockParsers[$i];
|
||||
$blockContinue = $blockParser->tryContinue(clone $this->cursor, $this->getActiveBlockParser());
|
||||
if ($blockContinue === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($blockContinue->isFinalize()) {
|
||||
$this->closeBlockParsers(\count($this->activeBlockParsers) - $i, $this->lineNumber);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($state = $blockContinue->getCursorState()) !== null) {
|
||||
$this->cursor->restoreState($state);
|
||||
}
|
||||
|
||||
$matches++;
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
private function findBlockStart(BlockContinueParserInterface $lastMatchedBlockParser): ?BlockStart
|
||||
{
|
||||
$matchedBlockParser = new MarkdownParserState($this->getActiveBlockParser(), $lastMatchedBlockParser);
|
||||
|
||||
foreach ($this->environment->getBlockStartParsers() as $blockStartParser) {
|
||||
\assert($blockStartParser instanceof BlockStartParserInterface);
|
||||
if (($result = $blockStartParser->tryStart(clone $this->cursor, $matchedBlockParser)) !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function closeBlockParsers(int $count, int $endLineNumber): void
|
||||
{
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$blockParser = $this->deactivateBlockParser();
|
||||
$this->finalize($blockParser, $endLineNumber);
|
||||
|
||||
// phpcs:disable SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed
|
||||
if ($blockParser instanceof BlockContinueParserWithInlinesInterface) {
|
||||
// Remember for inline parsing
|
||||
$this->closedBlockParsers[] = $blockParser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a block. Close it and do any necessary postprocessing, e.g. creating string_content from strings,
|
||||
* setting the 'tight' or 'loose' status of a list, and parsing the beginnings of paragraphs for reference
|
||||
* definitions.
|
||||
*/
|
||||
private function finalize(BlockContinueParserInterface $blockParser, int $endLineNumber): void
|
||||
{
|
||||
if ($blockParser instanceof ParagraphParser) {
|
||||
$this->updateReferenceMap($blockParser->getReferences());
|
||||
}
|
||||
|
||||
$blockParser->getBlock()->setEndLine($endLineNumber);
|
||||
$blockParser->closeBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk through a block & children recursively, parsing string content into inline content where appropriate.
|
||||
*/
|
||||
private function processInlines(int $inputSize): void
|
||||
{
|
||||
$p = new InlineParserEngine($this->environment, new MemoryLimitedReferenceMap($this->referenceMap, $inputSize));
|
||||
|
||||
foreach ($this->closedBlockParsers as $blockParser) {
|
||||
$blockParser->parseInlines($p);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add block of type tag as a child of the tip. If the tip can't accept children, close and finalize it and try
|
||||
* its parent, and so on til we find a block that can accept children.
|
||||
*/
|
||||
private function addChild(BlockContinueParserInterface $blockParser, ?int $startLineNumber = null): BlockContinueParserInterface
|
||||
{
|
||||
$blockParser->getBlock()->setStartLine($startLineNumber ?? $this->lineNumber);
|
||||
|
||||
while (! $this->getActiveBlockParser()->canContain($blockParser->getBlock())) {
|
||||
$this->closeBlockParsers(1, ($startLineNumber ?? $this->lineNumber) - 1);
|
||||
}
|
||||
|
||||
$this->getActiveBlockParser()->getBlock()->appendChild($blockParser->getBlock());
|
||||
$this->activateBlockParser($blockParser);
|
||||
|
||||
return $blockParser;
|
||||
}
|
||||
|
||||
private function activateBlockParser(BlockContinueParserInterface $blockParser): void
|
||||
{
|
||||
$this->activeBlockParsers[] = $blockParser;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ParserLogicException
|
||||
*/
|
||||
private function deactivateBlockParser(): BlockContinueParserInterface
|
||||
{
|
||||
$popped = \array_pop($this->activeBlockParsers);
|
||||
if ($popped === null) {
|
||||
throw new ParserLogicException('The last block parser should not be deactivated');
|
||||
}
|
||||
|
||||
return $popped;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|null The line number where the old block started
|
||||
*/
|
||||
private function prepareActiveBlockParserForReplacement(): ?int
|
||||
{
|
||||
// Note that we don't want to parse inlines or finalize this block, as it's getting replaced.
|
||||
$old = $this->deactivateBlockParser();
|
||||
|
||||
if ($old instanceof ParagraphParser) {
|
||||
$this->updateReferenceMap($old->getReferences());
|
||||
}
|
||||
|
||||
$old->getBlock()->detach();
|
||||
|
||||
return $old->getBlock()->getStartLine();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ReferenceInterface[] $references
|
||||
*/
|
||||
private function updateReferenceMap(iterable $references): void
|
||||
{
|
||||
foreach ($references as $reference) {
|
||||
if (! $this->referenceMap->contains($reference->getLabel())) {
|
||||
$this->referenceMap->add($reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ParserLogicException
|
||||
*/
|
||||
public function getActiveBlockParser(): BlockContinueParserInterface
|
||||
{
|
||||
$active = \end($this->activeBlockParsers);
|
||||
if ($active === false) {
|
||||
throw new ParserLogicException('No active block parsers are available');
|
||||
}
|
||||
|
||||
return $active;
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Reference;
|
||||
|
||||
use League\CommonMark\Normalizer\TextNormalizer;
|
||||
|
||||
/**
|
||||
* A collection of references, indexed by label
|
||||
*/
|
||||
final class ReferenceMap implements ReferenceMapInterface
|
||||
{
|
||||
/** @psalm-readonly */
|
||||
private TextNormalizer $normalizer;
|
||||
|
||||
/**
|
||||
* @var array<string, ReferenceInterface>
|
||||
*
|
||||
* @psalm-readonly-allow-private-mutation
|
||||
*/
|
||||
private array $references = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->normalizer = new TextNormalizer();
|
||||
}
|
||||
|
||||
public function add(ReferenceInterface $reference): void
|
||||
{
|
||||
// Normalize the key
|
||||
$key = $this->normalizer->normalize($reference->getLabel());
|
||||
// Store the reference
|
||||
$this->references[$key] = $reference;
|
||||
}
|
||||
|
||||
public function contains(string $label): bool
|
||||
{
|
||||
if ($this->references === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$label = $this->normalizer->normalize($label);
|
||||
|
||||
return isset($this->references[$label]);
|
||||
}
|
||||
|
||||
public function get(string $label): ?ReferenceInterface
|
||||
{
|
||||
if ($this->references === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = $this->normalizer->normalize($label);
|
||||
|
||||
return $this->references[$label] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Traversable<string, ReferenceInterface>
|
||||
*/
|
||||
public function getIterator(): \Traversable
|
||||
{
|
||||
foreach ($this->references as $normalizedLabel => $reference) {
|
||||
yield $normalizedLabel => $reference;
|
||||
}
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return \count($this->references);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Util;
|
||||
|
||||
use League\CommonMark\Parser\Cursor;
|
||||
|
||||
/**
|
||||
* @psalm-immutable
|
||||
*/
|
||||
final class LinkParserHelper
|
||||
{
|
||||
/**
|
||||
* Attempt to parse link destination
|
||||
*
|
||||
* @return string|null The string, or null if no match
|
||||
*/
|
||||
public static function parseLinkDestination(Cursor $cursor): ?string
|
||||
{
|
||||
if ($cursor->getCurrentCharacter() === '<') {
|
||||
return self::parseDestinationBraces($cursor);
|
||||
}
|
||||
|
||||
$destination = self::manuallyParseLinkDestination($cursor);
|
||||
if ($destination === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return UrlEncoder::unescapeAndEncode(
|
||||
RegexHelper::unescape($destination)
|
||||
);
|
||||
}
|
||||
|
||||
public static function parseLinkLabel(Cursor $cursor): int
|
||||
{
|
||||
$match = $cursor->match('/^\[(?:[^\\\\\[\]]|\\\\.){0,1000}\]/');
|
||||
if ($match === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$length = \mb_strlen($match, 'UTF-8');
|
||||
|
||||
if ($length > 1001) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $length;
|
||||
}
|
||||
|
||||
public static function parsePartialLinkLabel(Cursor $cursor): ?string
|
||||
{
|
||||
return $cursor->match('/^(?:[^\\\\\[\]]++|\\\\.?)*+/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse link title (sans quotes)
|
||||
*
|
||||
* @return string|null The string, or null if no match
|
||||
*/
|
||||
public static function parseLinkTitle(Cursor $cursor): ?string
|
||||
{
|
||||
if ($title = $cursor->match('/' . RegexHelper::PARTIAL_LINK_TITLE . '/')) {
|
||||
// Chop off quotes from title and unescape
|
||||
return RegexHelper::unescape(\substr($title, 1, -1));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function parsePartialLinkTitle(Cursor $cursor, string $endDelimiter): ?string
|
||||
{
|
||||
$endDelimiter = \preg_quote($endDelimiter, '/');
|
||||
$regex = \sprintf('/(%s|[^%s\x00])*(?:%s)?/', RegexHelper::PARTIAL_ESCAPED_CHAR, $endDelimiter, $endDelimiter);
|
||||
if (($partialTitle = $cursor->match($regex)) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RegexHelper::unescape($partialTitle);
|
||||
}
|
||||
|
||||
private static function manuallyParseLinkDestination(Cursor $cursor): ?string
|
||||
{
|
||||
$remainder = $cursor->getRemainder();
|
||||
$openParens = 0;
|
||||
$len = \strlen($remainder);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$c = $remainder[$i];
|
||||
if ($c === '\\' && $i + 1 < $len && RegexHelper::isEscapable($remainder[$i + 1])) {
|
||||
$i++;
|
||||
} elseif ($c === '(') {
|
||||
$openParens++;
|
||||
// Limit to 32 nested parens for pathological cases
|
||||
if ($openParens > 32) {
|
||||
return null;
|
||||
}
|
||||
} elseif ($c === ')') {
|
||||
if ($openParens < 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
$openParens--;
|
||||
} elseif (\ord($c) <= 32 && RegexHelper::isWhitespace($c)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($openParens !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($i === 0 && (! isset($c) || $c !== ')')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$destination = \substr($remainder, 0, $i);
|
||||
$cursor->advanceBy(\mb_strlen($destination, 'UTF-8'));
|
||||
|
||||
return $destination;
|
||||
}
|
||||
|
||||
/** @var \WeakReference<Cursor>|null */
|
||||
private static ?\WeakReference $lastCursor = null;
|
||||
private static bool $lastCursorLacksClosingBrace = false;
|
||||
|
||||
private static function parseDestinationBraces(Cursor $cursor): ?string
|
||||
{
|
||||
// Optimization: If we've previously parsed this cursor and returned `null`, we know
|
||||
// that no closing brace exists, so we can skip the regex entirely. This helps avoid
|
||||
// certain pathological cases where the regex engine can take a very long time to
|
||||
// determine that no match exists.
|
||||
if (self::$lastCursor !== null && self::$lastCursor->get() === $cursor) {
|
||||
if (self::$lastCursorLacksClosingBrace) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
self::$lastCursor = \WeakReference::create($cursor);
|
||||
}
|
||||
|
||||
if ($res = $cursor->match(RegexHelper::REGEX_LINK_DESTINATION_BRACES)) {
|
||||
self::$lastCursorLacksClosingBrace = false;
|
||||
|
||||
// Chop off surrounding <..>:
|
||||
return UrlEncoder::unescapeAndEncode(
|
||||
RegexHelper::unescape(\substr($res, 1, -1))
|
||||
);
|
||||
}
|
||||
|
||||
self::$lastCursorLacksClosingBrace = true;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
|
||||
* - (c) John MacFarlane
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Util;
|
||||
|
||||
use League\CommonMark\Exception\InvalidArgumentException;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
|
||||
|
||||
/**
|
||||
* Provides regular expressions and utilities for parsing Markdown
|
||||
*
|
||||
* All of the PARTIAL_ regex constants assume that they'll be used in case-insensitive searches
|
||||
* All other complete regexes provided by this class (either via constants or methods) will have case-insensitivity enabled.
|
||||
*
|
||||
* @phpcs:disable Generic.Strings.UnnecessaryStringConcat.Found
|
||||
*
|
||||
* @psalm-immutable
|
||||
*/
|
||||
final class RegexHelper
|
||||
{
|
||||
// Partial regular expressions (wrap with `/` on each side and add the case-insensitive `i` flag before use)
|
||||
public const PARTIAL_ENTITY = '&(?:#x[a-f0-9]{1,6}|#[0-9]{1,7}|[a-z][a-z0-9]{1,31});';
|
||||
public const PARTIAL_ESCAPABLE = '[!"#$%&\'()*+,.\/:;<=>?@[\\\\\]^_`{|}~-]';
|
||||
public const PARTIAL_ESCAPED_CHAR = '\\\\' . self::PARTIAL_ESCAPABLE;
|
||||
public const PARTIAL_IN_DOUBLE_QUOTES = '"(' . self::PARTIAL_ESCAPED_CHAR . '|[^"\x00])*"';
|
||||
public const PARTIAL_IN_SINGLE_QUOTES = '\'(' . self::PARTIAL_ESCAPED_CHAR . '|[^\'\x00])*\'';
|
||||
public const PARTIAL_IN_PARENS = '\\((' . self::PARTIAL_ESCAPED_CHAR . '|[^)\x00])*\\)';
|
||||
public const PARTIAL_REG_CHAR = '[^\\\\()\x00-\x20]';
|
||||
public const PARTIAL_IN_PARENS_NOSP = '\((' . self::PARTIAL_REG_CHAR . '|' . self::PARTIAL_ESCAPED_CHAR . '|\\\\)*\)';
|
||||
public const PARTIAL_TAGNAME = '[a-z][a-z0-9-]*';
|
||||
public const PARTIAL_BLOCKTAGNAME = '(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)';
|
||||
public const PARTIAL_ATTRIBUTENAME = '[a-z_:][a-z0-9:._-]*';
|
||||
public const PARTIAL_UNQUOTEDVALUE = '[^"\'=<>`\x00-\x20]+';
|
||||
public const PARTIAL_SINGLEQUOTEDVALUE = '\'[^\']*\'';
|
||||
public const PARTIAL_DOUBLEQUOTEDVALUE = '"[^"]*"';
|
||||
public const PARTIAL_ATTRIBUTEVALUE = '(?:' . self::PARTIAL_UNQUOTEDVALUE . '|' . self::PARTIAL_SINGLEQUOTEDVALUE . '|' . self::PARTIAL_DOUBLEQUOTEDVALUE . ')';
|
||||
public const PARTIAL_ATTRIBUTEVALUESPEC = '(?:' . '\s*=' . '\s*' . self::PARTIAL_ATTRIBUTEVALUE . ')';
|
||||
public const PARTIAL_ATTRIBUTE = '(?:' . '\s+' . self::PARTIAL_ATTRIBUTENAME . self::PARTIAL_ATTRIBUTEVALUESPEC . '?)';
|
||||
public const PARTIAL_OPENTAG = '<' . self::PARTIAL_TAGNAME . self::PARTIAL_ATTRIBUTE . '*' . '\s*\/?>';
|
||||
public const PARTIAL_CLOSETAG = '<\/' . self::PARTIAL_TAGNAME . '\s*[>]';
|
||||
public const PARTIAL_OPENBLOCKTAG = '<' . self::PARTIAL_BLOCKTAGNAME . self::PARTIAL_ATTRIBUTE . '*' . '\s*\/?>';
|
||||
public const PARTIAL_CLOSEBLOCKTAG = '<\/' . self::PARTIAL_BLOCKTAGNAME . '\s*[>]';
|
||||
public const PARTIAL_HTMLCOMMENT = '<!-->|<!--->|<!--[\s\S]*?-->';
|
||||
public const PARTIAL_PROCESSINGINSTRUCTION = '[<][?][\s\S]*?[?][>]';
|
||||
public const PARTIAL_DECLARATION = '<![A-Za-z]+' . '[^>]*>';
|
||||
public const PARTIAL_CDATA = '<!\[CDATA\[[\s\S]*?]\]>';
|
||||
public const PARTIAL_HTMLTAG = '(?:' . self::PARTIAL_OPENTAG . '|' . self::PARTIAL_CLOSETAG . '|' . self::PARTIAL_HTMLCOMMENT . '|' .
|
||||
self::PARTIAL_PROCESSINGINSTRUCTION . '|' . self::PARTIAL_DECLARATION . '|' . self::PARTIAL_CDATA . ')';
|
||||
public const PARTIAL_HTMLBLOCKOPEN = '<(?:' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s\/>]|$)' . '|' .
|
||||
'\/' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s>]|$)' . '|' . '[?!])';
|
||||
public const PARTIAL_LINK_TITLE = '^(?:"(' . self::PARTIAL_ESCAPED_CHAR . '|[^"\x00])*+"' .
|
||||
'|' . '\'(' . self::PARTIAL_ESCAPED_CHAR . '|[^\'\x00])*+\'' .
|
||||
'|' . '\((' . self::PARTIAL_ESCAPED_CHAR . '|[^()\x00])*+\))';
|
||||
|
||||
public const REGEX_PUNCTUATION = '/^[!"#$%&\'()*+,\-.\\/:;<=>?@\\[\\]\\\\^_`{|}~\p{P}\p{S}]/u';
|
||||
public const REGEX_UNSAFE_PROTOCOL = '/^javascript:|vbscript:|file:|data:/i';
|
||||
public const REGEX_SAFE_DATA_PROTOCOL = '/^data:image\/(?:png|gif|jpeg|webp)/i';
|
||||
public const REGEX_NON_SPACE = '/[^ \t\f\v\r\n]/';
|
||||
|
||||
public const REGEX_WHITESPACE_CHAR = '/^[ \t\n\x0b\x0c\x0d]/';
|
||||
public const REGEX_UNICODE_WHITESPACE_CHAR = '/^\pZ|\s/u';
|
||||
public const REGEX_THEMATIC_BREAK = '/^(?:\*[ \t]*){3,}$|^(?:_[ \t]*){3,}$|^(?:-[ \t]*){3,}$/';
|
||||
public const REGEX_LINK_DESTINATION_BRACES = '/^(?:<(?:[^<>\\n\\\\\\x00]|\\\\.)*>)/';
|
||||
|
||||
/**
|
||||
* @psalm-pure
|
||||
*/
|
||||
public static function isEscapable(string $character): bool
|
||||
{
|
||||
return \preg_match('/' . self::PARTIAL_ESCAPABLE . '/', $character) === 1;
|
||||
}
|
||||
|
||||
public static function isWhitespace(string $character): bool
|
||||
{
|
||||
/** @psalm-suppress InvalidLiteralArgument */
|
||||
return $character !== '' && \strpos(" \t\n\x0b\x0c\x0d", $character) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-pure
|
||||
*/
|
||||
public static function isLetter(?string $character): bool
|
||||
{
|
||||
if ($character === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \preg_match('/[\pL]/u', $character) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to match a regex in string s at offset offset
|
||||
*
|
||||
* @psalm-param non-empty-string $regex
|
||||
*
|
||||
* @return int|null Index of match, or null
|
||||
*
|
||||
* @psalm-pure
|
||||
*/
|
||||
public static function matchAt(string $regex, string $string, int $offset = 0): ?int
|
||||
{
|
||||
$matches = [];
|
||||
$string = \mb_substr($string, $offset, null, 'UTF-8');
|
||||
if (! \preg_match($regex, $string, $matches, \PREG_OFFSET_CAPTURE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// PREG_OFFSET_CAPTURE always returns the byte offset, not the char offset, which is annoying
|
||||
$charPos = \mb_strlen(\mb_strcut($string, 0, $matches[0][1], 'UTF-8'), 'UTF-8');
|
||||
|
||||
return $offset + $charPos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional wrapper around preg_match_all which only returns the first set of matches
|
||||
*
|
||||
* @psalm-param non-empty-string $pattern
|
||||
*
|
||||
* @return string[]|null
|
||||
*
|
||||
* @psalm-pure
|
||||
*/
|
||||
public static function matchFirst(string $pattern, string $subject, int $offset = 0): ?array
|
||||
{
|
||||
if ($offset !== 0) {
|
||||
$subject = \substr($subject, $offset);
|
||||
}
|
||||
|
||||
\preg_match_all($pattern, $subject, $matches, \PREG_SET_ORDER);
|
||||
|
||||
if ($matches === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $matches[0] ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace backslash escapes with literal characters
|
||||
*
|
||||
* @psalm-pure
|
||||
*/
|
||||
public static function unescape(string $string): string
|
||||
{
|
||||
$allEscapedChar = '/\\\\(' . self::PARTIAL_ESCAPABLE . ')/';
|
||||
|
||||
$escaped = \preg_replace($allEscapedChar, '$1', $string);
|
||||
\assert(\is_string($escaped));
|
||||
|
||||
return \preg_replace_callback('/' . self::PARTIAL_ENTITY . '/i', static fn ($e) => Html5EntityDecoder::decode($e[0]), $escaped);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @param int $type HTML block type
|
||||
*
|
||||
* @psalm-param HtmlBlock::TYPE_* $type
|
||||
*
|
||||
* @phpstan-param HtmlBlock::TYPE_* $type
|
||||
*
|
||||
* @psalm-return non-empty-string
|
||||
*
|
||||
* @throws InvalidArgumentException if an invalid type is given
|
||||
*
|
||||
* @psalm-pure
|
||||
*/
|
||||
public static function getHtmlBlockOpenRegex(int $type): string
|
||||
{
|
||||
switch ($type) {
|
||||
case HtmlBlock::TYPE_1_CODE_CONTAINER:
|
||||
return '/^<(?:script|pre|textarea|style)(?:\s|>|$)/i';
|
||||
case HtmlBlock::TYPE_2_COMMENT:
|
||||
return '/^<!--/';
|
||||
case HtmlBlock::TYPE_3:
|
||||
return '/^<[?]/';
|
||||
case HtmlBlock::TYPE_4:
|
||||
return '/^<![A-Z]/i';
|
||||
case HtmlBlock::TYPE_5_CDATA:
|
||||
return '/^<!\[CDATA\[/i';
|
||||
case HtmlBlock::TYPE_6_BLOCK_ELEMENT:
|
||||
return '%^<[/]?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[123456]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:\s|[/]?[>]|$)%i';
|
||||
case HtmlBlock::TYPE_7_MISC_ELEMENT:
|
||||
return '/^(?:' . self::PARTIAL_OPENTAG . '|' . self::PARTIAL_CLOSETAG . ')\\s*$/i';
|
||||
default:
|
||||
throw new InvalidArgumentException('Invalid HTML block type');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @param int $type HTML block type
|
||||
*
|
||||
* @psalm-param HtmlBlock::TYPE_* $type
|
||||
*
|
||||
* @phpstan-param HtmlBlock::TYPE_* $type
|
||||
*
|
||||
* @psalm-return non-empty-string
|
||||
*
|
||||
* @throws InvalidArgumentException if an invalid type is given
|
||||
*
|
||||
* @psalm-pure
|
||||
*/
|
||||
public static function getHtmlBlockCloseRegex(int $type): string
|
||||
{
|
||||
switch ($type) {
|
||||
case HtmlBlock::TYPE_1_CODE_CONTAINER:
|
||||
return '%<\/(?:script|pre|textarea|style)>%i';
|
||||
case HtmlBlock::TYPE_2_COMMENT:
|
||||
return '/-->/';
|
||||
case HtmlBlock::TYPE_3:
|
||||
return '/\?>/';
|
||||
case HtmlBlock::TYPE_4:
|
||||
return '/>/';
|
||||
case HtmlBlock::TYPE_5_CDATA:
|
||||
return '/\]\]>/';
|
||||
default:
|
||||
throw new InvalidArgumentException('Invalid HTML block type');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-pure
|
||||
*/
|
||||
public static function isLinkPotentiallyUnsafe(string $url): bool
|
||||
{
|
||||
return \preg_match(self::REGEX_UNSAFE_PROTOCOL, $url) !== 0 && \preg_match(self::REGEX_SAFE_DATA_PROTOCOL, $url) === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the league/commonmark package.
|
||||
*
|
||||
* (c) Colin O'Dell <colinodell@gmail.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace League\CommonMark\Util;
|
||||
|
||||
use League\CommonMark\Exception\IOException;
|
||||
|
||||
/**
|
||||
* Reads in a CommonMark spec document and extracts the input/output examples for testing against them
|
||||
*/
|
||||
final class SpecReader
|
||||
{
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{input: string, output: string, type: string, section: string, number: int}>
|
||||
*/
|
||||
public static function read(string $data): iterable
|
||||
{
|
||||
// Normalize newlines for platform independence
|
||||
$data = \preg_replace('/\r\n?/', "\n", $data);
|
||||
\assert($data !== null);
|
||||
$data = \preg_replace('/<!-- END TESTS -->.*$/', '', $data);
|
||||
\assert($data !== null);
|
||||
\preg_match_all('/^`{32} (example ?\w*)\n([\s\S]*?)^\.\n([\s\S]*?)^`{32}$|^#{1,6} *(.*)$/m', $data, $matches, PREG_SET_ORDER);
|
||||
|
||||
$currentSection = 'Example';
|
||||
$exampleNumber = 0;
|
||||
|
||||
foreach ($matches as $match) {
|
||||
\assert(isset($match[1], $match[2], $match[3]));
|
||||
if (isset($match[4])) {
|
||||
$currentSection = $match[4];
|
||||
continue;
|
||||
}
|
||||
|
||||
yield \trim($currentSection . ' #' . $exampleNumber) => [
|
||||
'input' => \str_replace('→', "\t", $match[2]),
|
||||
'output' => \str_replace('→', "\t", $match[3]),
|
||||
'type' => $match[1],
|
||||
'section' => $currentSection,
|
||||
'number' => $exampleNumber++,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{input: string, output: string, type: string, section: string, number: int}>
|
||||
*
|
||||
* @throws IOException if the file cannot be loaded
|
||||
*/
|
||||
public static function readFile(string $filename): iterable
|
||||
{
|
||||
if (($data = \file_get_contents($filename)) === false) {
|
||||
throw new IOException(\sprintf('Failed to load spec from %s', $filename));
|
||||
}
|
||||
|
||||
return self::read($data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user