resolved conflicts

This commit is contained in:
2025-03-31 03:46:05 +03:00
6454 changed files with 1520539 additions and 9 deletions
@@ -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;
}
@@ -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];
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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),
]),
]);
}
}
@@ -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;
}
}
@@ -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'),
));
}
}
@@ -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 dont 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['('];
}
}
@@ -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(),
);
}
}
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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), '/') . ']/';
}
}
@@ -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());
}
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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];
}
}
@@ -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();
}
}
@@ -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')));
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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');
}
}
@@ -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();
}
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
}