This commit is contained in:
Paolo A
2024-08-13 13:44:16 +00:00
parent 1bbb23088d
commit e796d76612
4001 changed files with 30101 additions and 40075 deletions

View File

@@ -14,18 +14,18 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Attributes;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\Attributes\Event\AttributesListener;
use League\CommonMark\Extension\Attributes\Parser\AttributesBlockParser;
use League\CommonMark\Extension\Attributes\Parser\AttributesBlockStartParser;
use League\CommonMark\Extension\Attributes\Parser\AttributesInlineParser;
use League\CommonMark\Extension\ExtensionInterface;
final class AttributesExtension implements ExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addBlockParser(new AttributesBlockParser());
$environment->addBlockStartParser(new AttributesBlockStartParser());
$environment->addInlineParser(new AttributesInlineParser());
$environment->addEventListener(DocumentParsedEvent::class, [new AttributesListener(), 'processDocument']);
}

View File

@@ -14,15 +14,14 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Attributes\Event;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Element\FencedCode;
use League\CommonMark\Block\Element\ListBlock;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\Attributes\Node\Attributes;
use League\CommonMark\Extension\Attributes\Node\AttributesInline;
use League\CommonMark\Extension\Attributes\Util\AttributesHelper;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Node\Inline\AbstractInline;
use League\CommonMark\Node\Node;
final class AttributesListener
@@ -32,16 +31,14 @@ final class AttributesListener
public function processDocument(DocumentParsedEvent $event): void
{
$walker = $event->getDocument()->walker();
while ($event = $walker->next()) {
$node = $event->getNode();
if (!$node instanceof AttributesInline && ($event->isEntering() || !$node instanceof Attributes)) {
foreach ($event->getDocument()->iterator() as $node) {
if (! ($node instanceof Attributes || $node instanceof AttributesInline)) {
continue;
}
[$target, $direction] = self::findTargetAndDirection($node);
if ($target instanceof AbstractBlock || $target instanceof AbstractInline) {
if ($target instanceof Node) {
$parent = $target->parent();
if ($parent instanceof ListItem && $parent->parent() instanceof ListBlock && $parent->parent()->isTight()) {
$target = $parent;
@@ -53,14 +50,7 @@ final class AttributesListener
$attributes = AttributesHelper::mergeAttributes($node->getAttributes(), $target);
}
$target->data['attributes'] = $attributes;
}
if ($node instanceof AbstractBlock && $node->endsWithBlankLine() && $node->next() && $node->previous()) {
$previous = $node->previous();
if ($previous instanceof AbstractBlock) {
$previous->setLastLineBlank(true);
}
$target->data->set('attributes', $attributes);
}
$node->detach();
@@ -68,22 +58,22 @@ final class AttributesListener
}
/**
* @param Node $node
* @param Attributes|AttributesInline $node
*
* @return array<Node|string|null>
*/
private static function findTargetAndDirection(Node $node): array
private static function findTargetAndDirection($node): array
{
$target = null;
$target = null;
$direction = null;
$previous = $next = $node;
$previous = $next = $node;
while (true) {
$previous = self::getPrevious($previous);
$next = self::getNext($next);
$next = self::getNext($next);
if ($previous === null && $next === null) {
if (!$node->parent() instanceof FencedCode) {
$target = $node->parent();
if (! $node->parent() instanceof FencedCode) {
$target = $node->parent();
$direction = self::DIRECTION_SUFFIX;
}
@@ -94,15 +84,15 @@ final class AttributesListener
continue;
}
if ($previous !== null && !self::isAttributesNode($previous)) {
$target = $previous;
if ($previous !== null && ! self::isAttributesNode($previous)) {
$target = $previous;
$direction = self::DIRECTION_SUFFIX;
break;
}
if ($next !== null && !self::isAttributesNode($next)) {
$target = $next;
if ($next !== null && ! self::isAttributesNode($next)) {
$target = $next;
$direction = self::DIRECTION_PREFIX;
break;
@@ -112,26 +102,34 @@ final class AttributesListener
return [$target, $direction];
}
/**
* Get any previous block (sibling or parent) this might apply to
*/
private static function getPrevious(?Node $node = null): ?Node
{
$previous = $node instanceof Node ? $node->previous() : null;
if ($node instanceof Attributes) {
if ($node->getTarget() === Attributes::TARGET_NEXT) {
return null;
}
if ($previous instanceof AbstractBlock && $previous->endsWithBlankLine()) {
$previous = null;
if ($node->getTarget() === Attributes::TARGET_PARENT) {
return $node->parent();
}
}
return $previous;
return $node instanceof Node ? $node->previous() : null;
}
/**
* Get any previous block (sibling or parent) this might apply to
*/
private static function getNext(?Node $node = null): ?Node
{
$next = $node instanceof Node ? $node->next() : null;
if ($node instanceof AbstractBlock && $node->endsWithBlankLine()) {
$next = null;
if ($node instanceof Attributes && $node->getTarget() !== Attributes::TARGET_NEXT) {
return null;
}
return $next;
return $node instanceof Node ? $node->next() : null;
}
private static function isAttributesNode(Node $node): bool

View File

@@ -14,19 +14,26 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Attributes\Node;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Cursor;
use League\CommonMark\Node\Block\AbstractBlock;
final class Attributes extends AbstractBlock
{
public const TARGET_PARENT = 0;
public const TARGET_PREVIOUS = 1;
public const TARGET_NEXT = 2;
/** @var array<string, mixed> */
private $attributes;
private array $attributes;
private int $target = self::TARGET_NEXT;
/**
* @param array<string, mixed> $attributes
*/
public function __construct(array $attributes)
{
parent::__construct();
$this->attributes = $attributes;
}
@@ -38,25 +45,21 @@ final class Attributes extends AbstractBlock
return $this->attributes;
}
public function canContain(AbstractBlock $block): bool
/**
* @param array<string, mixed> $attributes
*/
public function setAttributes(array $attributes): void
{
return false;
$this->attributes = $attributes;
}
public function isCode(): bool
public function getTarget(): int
{
return false;
return $this->target;
}
public function matchesNextLine(Cursor $cursor): bool
public function setTarget(int $target): void
{
$this->setLastLineBlank($cursor->isBlank());
return false;
}
public function shouldLastLineBeBlank(Cursor $cursor, int $currentLineNumber): bool
{
return false;
$this->target = $target;
}
}

View File

@@ -14,25 +14,24 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Attributes\Node;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Inline\AbstractInline;
final class AttributesInline extends AbstractInline
{
/** @var array<string, mixed> */
public $attributes;
private array $attributes;
/** @var bool */
public $block;
private bool $block;
/**
* @param array<string, mixed> $attributes
* @param bool $block
*/
public function __construct(array $attributes, bool $block)
{
parent::__construct();
$this->attributes = $attributes;
$this->block = $block;
$this->data = ['delim' => true]; // TODO: Re-implement as a delimiter?
$this->block = $block;
}
/**
@@ -43,6 +42,14 @@ final class AttributesInline extends AbstractInline
return $this->attributes;
}
/**
* @param array<string, mixed> $attributes
*/
public function setAttributes(array $attributes): void
{
$this->attributes = $attributes;
}
public function isBlock(): bool
{
return $this->block;

View File

@@ -1,44 +0,0 @@
<?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\Parser;
use League\CommonMark\Block\Parser\BlockParserInterface;
use League\CommonMark\ContextInterface;
use League\CommonMark\Cursor;
use League\CommonMark\Extension\Attributes\Node\Attributes;
use League\CommonMark\Extension\Attributes\Util\AttributesHelper;
final class AttributesBlockParser implements BlockParserInterface
{
public function parse(ContextInterface $context, Cursor $cursor): bool
{
$state = $cursor->saveState();
$attributes = AttributesHelper::parseAttributes($cursor);
if ($attributes === []) {
return false;
}
if ($cursor->getNextNonSpaceCharacter() !== null) {
$cursor->restoreState($state);
return false;
}
$context->addBlock(new Attributes($attributes));
$context->setBlocksParsed(true);
return true;
}
}

View File

@@ -16,33 +16,30 @@ namespace League\CommonMark\Extension\Attributes\Parser;
use League\CommonMark\Extension\Attributes\Node\AttributesInline;
use League\CommonMark\Extension\Attributes\Util\AttributesHelper;
use League\CommonMark\Inline\Element\Text;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\InlineParserContext;
use League\CommonMark\Node\StringContainerInterface;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
final class AttributesInlineParser implements InlineParserInterface
{
/**
* {@inheritdoc}
*/
public function getCharacters(): array
public function getMatchDefinition(): InlineParserMatch
{
return ['{'];
return InlineParserMatch::string('{');
}
public function parse(InlineParserContext $inlineContext): bool
{
$cursor = $inlineContext->getCursor();
$char = (string) $cursor->peek(-1);
$char = (string) $cursor->peek(-1);
$attributes = AttributesHelper::parseAttributes($cursor);
if ($attributes === []) {
return false;
}
if ($char === ' ' && ($previousInline = $inlineContext->getContainer()->lastChild()) instanceof Text) {
$previousInline->setContent(\rtrim($previousInline->getContent(), ' '));
if ($char === ' ' && ($prev = $inlineContext->getContainer()->lastChild()) instanceof StringContainerInterface) {
$prev->setLiteral(\rtrim($prev->getLiteral(), ' '));
}
if ($char === '') {

View File

@@ -14,9 +14,8 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Attributes\Util;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Cursor;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Node;
use League\CommonMark\Parser\Cursor;
use League\CommonMark\Util\RegexHelper;
/**
@@ -24,29 +23,43 @@ use League\CommonMark\Util\RegexHelper;
*/
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';
/**
* @param Cursor $cursor
*
* @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 [];
}
$cursor->advanceBy(1);
if ($cursor->getCharacter() === ':') {
$cursor->advanceBy(1);
// 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 = [];
$regex = '/^\s*([.#][_a-z0-9-]+|' . RegexHelper::PARTIAL_ATTRIBUTENAME . RegexHelper::PARTIAL_ATTRIBUTEVALUESPEC . ')(?<!})\s*/i';
while ($attribute = \trim((string) $cursor->match($regex))) {
while ($attribute = \trim((string) $attributeCursor->match('/^' . self::SINGLE_ATTRIBUTE . '/i'))) {
if ($attribute[0] === '#') {
$attributes['id'] = \substr($attribute, 1);
@@ -59,10 +72,18 @@ final class AttributesHelper
continue;
}
[$name, $value] = \explode('=', $attribute, 2);
$parts = \explode('=', $attribute, 2);
if (\count($parts) === 1) {
$attributes[$attribute] = true;
continue;
}
/** @psalm-suppress PossiblyUndefinedArrayOffset */
[$name, $value] = $parts;
$first = $value[0];
$last = \substr($value, -1);
if ((($first === '"' && $last === '"') || ($first === "'" && $last === "'")) && \strlen($value) > 1) {
$last = \substr($value, -1);
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'") && \strlen($value) > 1) {
$value = \substr($value, 1, -1);
}
@@ -71,22 +92,10 @@ final class AttributesHelper
$attributes['class'][] = $class;
}
} else {
$attributes[trim($name)] = trim($value);
$attributes[\trim($name)] = \trim($value);
}
}
if ($cursor->match('/}/') === null) {
$cursor->restoreState($state);
return [];
}
if ($attributes === []) {
$cursor->restoreState($state);
return [];
}
if (isset($attributes['class'])) {
$attributes['class'] = \implode(' ', (array) $attributes['class']);
}
@@ -95,8 +104,8 @@ final class AttributesHelper
}
/**
* @param AbstractBlock|AbstractInline|array<string, mixed> $attributes1
* @param AbstractBlock|AbstractInline|array<string, mixed> $attributes2
* @param Node|array<string, mixed> $attributes1
* @param Node|array<string, mixed> $attributes2
*
* @return array<string, mixed>
*/
@@ -104,14 +113,18 @@ final class AttributesHelper
{
$attributes = [];
foreach ([$attributes1, $attributes2] as $arg) {
if ($arg instanceof AbstractBlock || $arg instanceof AbstractInline) {
$arg = $arg->data['attributes'] ?? [];
if ($arg instanceof Node) {
$arg = $arg->data->get('attributes');
}
/** @var array<string, mixed> $arg */
$arg = (array) $arg;
if (isset($arg['class'])) {
foreach (\array_filter(\explode(' ', \trim($arg['class']))) as $class) {
if (\is_string($arg['class'])) {
$arg['class'] = \array_filter(\explode(' ', \trim($arg['class'])));
}
foreach ($arg['class'] as $class) {
$attributes['class'][] = $class;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,15 +13,27 @@
namespace League\CommonMark\Extension\Autolink;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class AutolinkExtension implements ExtensionInterface
final class AutolinkExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$environment->addEventListener(DocumentParsedEvent::class, new EmailAutolinkProcessor());
$environment->addEventListener(DocumentParsedEvent::class, new UrlAutolinkProcessor());
$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'),
));
}
}

View File

@@ -1,78 +0,0 @@
<?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.
*/
namespace League\CommonMark\Extension\Autolink;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Inline\Element\Link;
use League\CommonMark\Inline\Element\Text;
final class EmailAutolinkProcessor
{
const REGEX = '/([A-Za-z0-9.\-_+]+@[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_.]+)/';
/**
* @param DocumentParsedEvent $e
*
* @return void
*/
public function __invoke(DocumentParsedEvent $e)
{
$walker = $e->getDocument()->walker();
while ($event = $walker->next()) {
$node = $event->getNode();
if ($node instanceof Text && !($node->parent() instanceof Link)) {
self::processAutolinks($node);
}
}
}
private static function processAutolinks(Text $node): void
{
$contents = \preg_split(self::REGEX, $node->getContent(), -1, PREG_SPLIT_DELIM_CAPTURE);
if ($contents === false || \count($contents) === 1) {
return;
}
$leftovers = '';
foreach ($contents as $i => $content) {
if ($i % 2 === 0) {
$text = $leftovers . $content;
if ($text !== '') {
$node->insertBefore(new Text($leftovers . $content));
}
$leftovers = '';
continue;
}
// Does the URL end with punctuation that should be stripped?
if (\substr($content, -1) === '.') {
// Add the punctuation later
$content = \substr($content, 0, -1);
$leftovers = '.';
}
// The last character cannot be - or _
if (\in_array(\substr($content, -1), ['-', '_'])) {
$node->insertBefore(new Text($content . $leftovers));
$leftovers = '';
continue;
}
$node->insertBefore(new Link('mailto:' . $content, $content));
}
$node->detach();
}
}

View File

@@ -1,96 +0,0 @@
<?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.
*/
namespace League\CommonMark\Extension\Autolink;
use League\CommonMark\Extension\Mention\MentionParser;
use League\CommonMark\Inline\Element\Link;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\InlineParserContext;
@trigger_error(sprintf('%s is deprecated; use %s instead', InlineMentionParser::class, MentionParser::class), E_USER_DEPRECATED);
/**
* @deprecated Use MentionParser instead
*/
final class InlineMentionParser implements InlineParserInterface
{
/** @var string */
private $linkPattern;
/** @var string */
private $handleRegex;
/**
* @param string $linkPattern
* @param string $handleRegex
*/
public function __construct($linkPattern, $handleRegex = '/^[A-Za-z0-9_]+(?!\w)/')
{
$this->linkPattern = $linkPattern;
$this->handleRegex = $handleRegex;
}
public function getCharacters(): array
{
return ['@'];
}
public function parse(InlineParserContext $inlineContext): bool
{
$cursor = $inlineContext->getCursor();
// The @ symbol must not have any other characters immediately prior
$previousChar = $cursor->peek(-1);
if ($previousChar !== null && $previousChar !== ' ') {
// peek() doesn't modify the cursor, so no need to restore state first
return false;
}
// Save the cursor state in case we need to rewind and bail
$previousState = $cursor->saveState();
// Advance past the @ symbol to keep parsing simpler
$cursor->advance();
// Parse the handle
$handle = $cursor->match($this->handleRegex);
if (empty($handle)) {
// Regex failed to match; this isn't a valid Twitter handle
$cursor->restoreState($previousState);
return false;
}
$url = \sprintf($this->linkPattern, $handle);
$inlineContext->getContainer()->appendChild(new Link($url, '@' . $handle));
return true;
}
/**
* @return InlineMentionParser
*/
public static function createTwitterHandleParser()
{
return new self('https://twitter.com/%s', '/^[A-Za-z0-9_]{1,15}(?!\w)/');
}
/**
* @return InlineMentionParser
*/
public static function createGithubHandleParser()
{
// RegEx adapted from https://github.com/shinnn/github-username-regex/blob/master/index.js
return new self('https://www.github.com/%s', '/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)/');
}
}

View File

@@ -1,153 +0,0 @@
<?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.
*/
namespace League\CommonMark\Extension\Autolink;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Inline\Element\Link;
use League\CommonMark\Inline\Element\Text;
final class UrlAutolinkProcessor
{
// RegEx adapted from https://github.com/symfony/symfony/blob/4.2/src/Symfony/Component/Validator/Constraints/UrlValidator.php
const REGEX = '~
(?<=^|[ \\t\\n\\x0b\\x0c\\x0d*_\\~\\(]) # Can only come at the beginning of a line, after whitespace, or certain delimiting characters
(
# Must start with a supported scheme + auth, or "www"
(?:
(?:%s):// # protocol
(?:([\.\pL\pN-]+:)?([\.\pL\pN-]+)@)? # basic auth
|www\.)
(?:
(?:[\pL\pN\pS\-\.])+(?:\.?(?:[\pL\pN]|xn\-\-[\pL\pN-]+)+\.?) # a 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 */
private $finalRegex;
/**
* @param array<int, string> $allowedProtocols
*/
public function __construct(array $allowedProtocols = ['http', 'https', 'ftp'])
{
$this->finalRegex = \sprintf(self::REGEX, \implode('|', $allowedProtocols));
}
/**
* @param DocumentParsedEvent $e
*
* @return void
*/
public function __invoke(DocumentParsedEvent $e)
{
$walker = $e->getDocument()->walker();
while ($event = $walker->next()) {
$node = $event->getNode();
if ($node instanceof Text && !($node->parent() instanceof Link)) {
self::processAutolinks($node, $this->finalRegex);
}
}
}
private static function processAutolinks(Text $node, string $regex): void
{
$contents = \preg_split($regex, $node->getContent(), -1, PREG_SPLIT_DELIM_CAPTURE);
if ($contents === false || \count($contents) === 1) {
return;
}
$leftovers = '';
foreach ($contents as $i => $content) {
// Even-indexed elements are things before/after the URLs
if ($i % 2 === 0) {
// Insert any left-over characters here as well
$text = $leftovers . $content;
if ($text !== '') {
$node->insertBefore(new Text($leftovers . $content));
}
$leftovers = '';
continue;
}
$leftovers = '';
// Does the URL end with punctuation that should be stripped?
if (\preg_match('/(.+)([?!.,:*_~]+)$/', $content, $matches)) {
// Add the punctuation later
$content = $matches[1];
$leftovers = $matches[2];
}
// Does the URL end with something that looks like an entity reference?
if (\preg_match('/(.+)(&[A-Za-z0-9]+;)$/', $content, $matches)) {
$content = $matches[1];
$leftovers = $matches[2] . $leftovers;
}
// Does the URL need its closing paren chopped off?
if (\substr($content, -1) === ')' && ($diff = self::diffParens($content)) > 0) {
$content = \substr($content, 0, -$diff);
$leftovers = str_repeat(')', $diff) . $leftovers;
}
self::addLink($node, $content);
}
$node->detach();
}
private static function addLink(Text $node, string $url): void
{
// Auto-prefix 'http://' onto 'www' URLs
if (\substr($url, 0, 4) === 'www.') {
$node->insertBefore(new Link('http://' . $url, $url));
return;
}
$node->insertBefore(new Link($url, $url));
}
/**
* @param string $content
*
* @return int
*/
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['('];
}
}

View File

@@ -1,95 +0,0 @@
<?php
/*
* 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;
use League\CommonMark\Block\Element as BlockElement;
use League\CommonMark\Block\Parser as BlockParser;
use League\CommonMark\Block\Renderer as BlockRenderer;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Delimiter\Processor\EmphasisDelimiterProcessor;
use League\CommonMark\Inline\Element as InlineElement;
use League\CommonMark\Inline\Parser as InlineParser;
use League\CommonMark\Inline\Renderer as InlineRenderer;
use League\CommonMark\Util\ConfigurationInterface;
final class CommonMarkCoreExtension implements ExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
{
$environment
->addBlockParser(new BlockParser\BlockQuoteParser(), 70)
->addBlockParser(new BlockParser\ATXHeadingParser(), 60)
->addBlockParser(new BlockParser\FencedCodeParser(), 50)
->addBlockParser(new BlockParser\HtmlBlockParser(), 40)
->addBlockParser(new BlockParser\SetExtHeadingParser(), 30)
->addBlockParser(new BlockParser\ThematicBreakParser(), 20)
->addBlockParser(new BlockParser\ListParser(), 10)
->addBlockParser(new BlockParser\IndentedCodeParser(), -100)
->addBlockParser(new BlockParser\LazyParagraphParser(), -200)
->addInlineParser(new InlineParser\NewlineParser(), 200)
->addInlineParser(new InlineParser\BacktickParser(), 150)
->addInlineParser(new InlineParser\EscapableParser(), 80)
->addInlineParser(new InlineParser\EntityParser(), 70)
->addInlineParser(new InlineParser\AutolinkParser(), 50)
->addInlineParser(new InlineParser\HtmlInlineParser(), 40)
->addInlineParser(new InlineParser\CloseBracketParser(), 30)
->addInlineParser(new InlineParser\OpenBracketParser(), 20)
->addInlineParser(new InlineParser\BangParser(), 10)
->addBlockRenderer(BlockElement\BlockQuote::class, new BlockRenderer\BlockQuoteRenderer(), 0)
->addBlockRenderer(BlockElement\Document::class, new BlockRenderer\DocumentRenderer(), 0)
->addBlockRenderer(BlockElement\FencedCode::class, new BlockRenderer\FencedCodeRenderer(), 0)
->addBlockRenderer(BlockElement\Heading::class, new BlockRenderer\HeadingRenderer(), 0)
->addBlockRenderer(BlockElement\HtmlBlock::class, new BlockRenderer\HtmlBlockRenderer(), 0)
->addBlockRenderer(BlockElement\IndentedCode::class, new BlockRenderer\IndentedCodeRenderer(), 0)
->addBlockRenderer(BlockElement\ListBlock::class, new BlockRenderer\ListBlockRenderer(), 0)
->addBlockRenderer(BlockElement\ListItem::class, new BlockRenderer\ListItemRenderer(), 0)
->addBlockRenderer(BlockElement\Paragraph::class, new BlockRenderer\ParagraphRenderer(), 0)
->addBlockRenderer(BlockElement\ThematicBreak::class, new BlockRenderer\ThematicBreakRenderer(), 0)
->addInlineRenderer(InlineElement\Code::class, new InlineRenderer\CodeRenderer(), 0)
->addInlineRenderer(InlineElement\Emphasis::class, new InlineRenderer\EmphasisRenderer(), 0)
->addInlineRenderer(InlineElement\HtmlInline::class, new InlineRenderer\HtmlInlineRenderer(), 0)
->addInlineRenderer(InlineElement\Image::class, new InlineRenderer\ImageRenderer(), 0)
->addInlineRenderer(InlineElement\Link::class, new InlineRenderer\LinkRenderer(), 0)
->addInlineRenderer(InlineElement\Newline::class, new InlineRenderer\NewlineRenderer(), 0)
->addInlineRenderer(InlineElement\Strong::class, new InlineRenderer\StrongRenderer(), 0)
->addInlineRenderer(InlineElement\Text::class, new InlineRenderer\TextRenderer(), 0)
;
$deprecatedUseAsterisk = $environment->getConfig('use_asterisk', ConfigurationInterface::MISSING);
if ($deprecatedUseAsterisk !== ConfigurationInterface::MISSING) {
@\trigger_error('The "use_asterisk" configuration option is deprecated in league/commonmark 1.6 and will be replaced with "commonmark > use_asterisk" in 2.0', \E_USER_DEPRECATED);
} else {
$deprecatedUseAsterisk = true;
}
if ($environment->getConfig('commonmark/use_asterisk', $deprecatedUseAsterisk)) {
$environment->addDelimiterProcessor(new EmphasisDelimiterProcessor('*'));
}
$deprecatedUseUnderscore = $environment->getConfig('use_underscore', ConfigurationInterface::MISSING);
if ($deprecatedUseUnderscore !== ConfigurationInterface::MISSING) {
@\trigger_error('The "use_underscore" configuration option is deprecated in league/commonmark 1.6 and will be replaced with "commonmark > use_underscore" in 2.0', \E_USER_DEPRECATED);
} else {
$deprecatedUseUnderscore = true;
}
if ($environment->getConfig('commonmark/use_underscore', $deprecatedUseUnderscore)) {
$environment->addDelimiterProcessor(new EmphasisDelimiterProcessor('_'));
}
}
}

View File

@@ -1,48 +0,0 @@
<?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.
*/
namespace League\CommonMark\Extension\DisallowedRawHtml;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
final class DisallowedRawHtmlBlockRenderer implements BlockRendererInterface, ConfigurationAwareInterface
{
/** @var BlockRendererInterface */
private $htmlBlockRenderer;
public function __construct(BlockRendererInterface $htmlBlockRenderer)
{
$this->htmlBlockRenderer = $htmlBlockRenderer;
}
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
{
$rendered = $this->htmlBlockRenderer->render($block, $htmlRenderer, $inTightList);
if ($rendered === '') {
return '';
}
// Match these types of tags: <title> </title> <title x="sdf"> <title/> <title />
return preg_replace('/<(\/?(?:title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)[ \/>])/i', '&lt;$1', $rendered);
}
public function setConfiguration(ConfigurationInterface $configuration)
{
if ($this->htmlBlockRenderer instanceof ConfigurationAwareInterface) {
$this->htmlBlockRenderer->setConfiguration($configuration);
}
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,18 +13,39 @@
namespace League\CommonMark\Extension\DisallowedRawHtml;
use League\CommonMark\Block\Element\HtmlBlock;
use League\CommonMark\Block\Renderer\HtmlBlockRenderer;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Inline\Element\HtmlInline;
use League\CommonMark\Inline\Renderer\HtmlInlineRenderer;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
use League\CommonMark\Extension\CommonMark\Renderer\Block\HtmlBlockRenderer;
use League\CommonMark\Extension\CommonMark\Renderer\Inline\HtmlInlineRenderer;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class DisallowedRawHtmlExtension implements ExtensionInterface
final class DisallowedRawHtmlExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
private const DEFAULT_DISALLOWED_TAGS = [
'title',
'textarea',
'style',
'xmp',
'iframe',
'noembed',
'noframes',
'script',
'plaintext',
];
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$environment->addBlockRenderer(HtmlBlock::class, new DisallowedRawHtmlBlockRenderer(new HtmlBlockRenderer()), 50);
$environment->addInlineRenderer(HtmlInline::class, new DisallowedRawHtmlInlineRenderer(new HtmlInlineRenderer()), 50);
$builder->addSchema('disallowed_raw_html', Expect::structure([
'disallowed_tags' => Expect::listOf('string')->default(self::DEFAULT_DISALLOWED_TAGS)->mergeDefaults(false),
]));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addRenderer(HtmlBlock::class, new DisallowedRawHtmlRenderer(new HtmlBlockRenderer()), 50);
$environment->addRenderer(HtmlInline::class, new DisallowedRawHtmlRenderer(new HtmlInlineRenderer()), 50);
}
}

View File

@@ -1,48 +0,0 @@
<?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.
*/
namespace League\CommonMark\Extension\DisallowedRawHtml;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
final class DisallowedRawHtmlInlineRenderer implements InlineRendererInterface, ConfigurationAwareInterface
{
/** @var InlineRendererInterface */
private $htmlInlineRenderer;
public function __construct(InlineRendererInterface $htmlBlockRenderer)
{
$this->htmlInlineRenderer = $htmlBlockRenderer;
}
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
{
$rendered = $this->htmlInlineRenderer->render($inline, $htmlRenderer);
if ($rendered === '') {
return '';
}
// Match these types of tags: <title> </title> <title x="sdf"> <title/> <title />
return preg_replace('/<(\/?(?:title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)[ \/>])/i', '&lt;$1', $rendered);
}
public function setConfiguration(ConfigurationInterface $configuration)
{
if ($this->htmlInlineRenderer instanceof ConfigurationAwareInterface) {
$this->htmlInlineRenderer->setConfiguration($configuration);
}
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -14,14 +16,9 @@
namespace League\CommonMark\Extension;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
interface ExtensionInterface
{
/**
* @param ConfigurableEnvironmentInterface $environment
*
* @return void
*/
public function register(ConfigurableEnvironmentInterface $environment);
public function register(EnvironmentBuilderInterface $environment): void;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,14 +13,35 @@
namespace League\CommonMark\Extension\ExternalLink;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class ExternalLinkExtension implements ExtensionInterface
final class ExternalLinkExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$environment->addEventListener(DocumentParsedEvent::class, new ExternalLinkProcessor($environment), -50);
$applyOptions = [
ExternalLinkProcessor::APPLY_NONE,
ExternalLinkProcessor::APPLY_ALL,
ExternalLinkProcessor::APPLY_INTERNAL,
ExternalLinkProcessor::APPLY_EXTERNAL,
];
$builder->addSchema('external_link', Expect::structure([
'internal_hosts' => Expect::type('string|string[]'),
'open_in_new_window' => Expect::bool(false),
'html_class' => Expect::string()->default(''),
'nofollow' => Expect::anyOf(...$applyOptions)->default(ExternalLinkProcessor::APPLY_NONE),
'noopener' => Expect::anyOf(...$applyOptions)->default(ExternalLinkProcessor::APPLY_EXTERNAL),
'noreferrer' => Expect::anyOf(...$applyOptions)->default(ExternalLinkProcessor::APPLY_EXTERNAL),
]));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addEventListener(DocumentParsedEvent::class, new ExternalLinkProcessor($environment->getConfiguration()), -50);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,114 +13,100 @@
namespace League\CommonMark\Extension\ExternalLink;
use League\CommonMark\EnvironmentInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Inline\Element\Link;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\Config\ConfigurationInterface;
final class ExternalLinkProcessor
{
public const APPLY_NONE = '';
public const APPLY_ALL = 'all';
public const APPLY_NONE = '';
public const APPLY_ALL = 'all';
public const APPLY_EXTERNAL = 'external';
public const APPLY_INTERNAL = 'internal';
/** @var EnvironmentInterface */
private $environment;
/** @psalm-readonly */
private ConfigurationInterface $config;
public function __construct(EnvironmentInterface $environment)
public function __construct(ConfigurationInterface $config)
{
$this->environment = $environment;
$this->config = $config;
}
/**
* @param DocumentParsedEvent $e
*
* @return void
*/
public function __invoke(DocumentParsedEvent $e)
public function __invoke(DocumentParsedEvent $e): void
{
$internalHosts = $this->environment->getConfig('external_link/internal_hosts', []);
$openInNewWindow = $this->environment->getConfig('external_link/open_in_new_window', false);
$classes = $this->environment->getConfig('external_link/html_class', '');
$internalHosts = $this->config->get('external_link/internal_hosts');
$openInNewWindow = $this->config->get('external_link/open_in_new_window');
$classes = $this->config->get('external_link/html_class');
$walker = $e->getDocument()->walker();
while ($event = $walker->next()) {
if ($event->isEntering() && $event->getNode() instanceof Link) {
/** @var Link $link */
$link = $event->getNode();
$host = parse_url($link->getUrl(), PHP_URL_HOST);
if (empty($host)) {
// Something is terribly wrong with this URL
continue;
}
if (self::hostMatches($host, $internalHosts)) {
$link->data['external'] = false;
$this->applyRelAttribute($link, false);
continue;
}
// Host does not match our list
$this->markLinkAsExternal($link, $openInNewWindow, $classes);
foreach ($e->getDocument()->iterator() as $link) {
if (! ($link instanceof Link)) {
continue;
}
$host = \parse_url($link->getUrl(), PHP_URL_HOST);
if (! \is_string($host)) {
// Something is terribly wrong with this URL
continue;
}
if (self::hostMatches($host, $internalHosts)) {
$link->data->set('external', false);
$this->applyRelAttribute($link, false);
continue;
}
// Host does not match our list
$this->markLinkAsExternal($link, $openInNewWindow, $classes);
}
}
private function markLinkAsExternal(Link $link, bool $openInNewWindow, string $classes): void
{
$link->data['external'] = true;
$link->data['attributes'] = $link->getData('attributes', []);
$link->data->set('external', true);
$this->applyRelAttribute($link, true);
if ($openInNewWindow) {
$link->data['attributes']['target'] = '_blank';
$link->data->set('attributes/target', '_blank');
}
if (!empty($classes)) {
$link->data['attributes']['class'] = trim(($link->data['attributes']['class'] ?? '') . ' ' . $classes);
if ($classes !== '') {
$link->data->append('attributes/class', $classes);
}
}
private function applyRelAttribute(Link $link, bool $isExternal): void
{
$rel = [];
$options = [
'nofollow' => $this->environment->getConfig('external_link/nofollow', self::APPLY_NONE),
'noopener' => $this->environment->getConfig('external_link/noopener', self::APPLY_EXTERNAL),
'noreferrer' => $this->environment->getConfig('external_link/noreferrer', self::APPLY_EXTERNAL),
'nofollow' => $this->config->get('external_link/nofollow'),
'noopener' => $this->config->get('external_link/noopener'),
'noreferrer' => $this->config->get('external_link/noreferrer'),
];
foreach ($options as $type => $option) {
switch (true) {
case $option === self::APPLY_ALL:
case $isExternal && $option === self::APPLY_EXTERNAL:
case !$isExternal && $option === self::APPLY_INTERNAL:
$rel[] = $type;
case ! $isExternal && $option === self::APPLY_INTERNAL:
$link->data->append('attributes/rel', $type);
}
}
if ($rel === []) {
return;
// No rel attributes? Mark the attribute as 'false' so LinkRenderer doesn't add defaults
if (! $link->data->has('attributes/rel')) {
$link->data->set('attributes/rel', false);
}
$link->data['attributes']['rel'] = \implode(' ', $rel);
}
/**
* @param string $host
* @param mixed $compareTo
*
* @return bool
*
* @internal This method is only public so we can easily test it. DO NOT USE THIS OUTSIDE OF THIS EXTENSION!
*
* @param non-empty-string|list<non-empty-string> $compareTo
*/
public static function hostMatches(string $host, $compareTo)
public static function hostMatches(string $host, $compareTo): bool
{
foreach ((array) $compareTo as $c) {
if (strpos($c, '/') === 0) {
if (preg_match($c, $host)) {
if (\strpos($c, '/') === 0) {
if (\preg_match($c, $host)) {
return true;
}
} elseif ($c === $host) {

View File

@@ -14,48 +14,49 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Event;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\Footnote\Node\Footnote;
use League\CommonMark\Extension\Footnote\Node\FootnoteBackref;
use League\CommonMark\Extension\Footnote\Node\FootnoteRef;
use League\CommonMark\Inline\Element\Text;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Reference\Reference;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
final class AnonymousFootnotesListener implements ConfigurationAwareInterface
{
/** @var ConfigurationInterface */
private $config;
private ConfigurationInterface $config;
public function onDocumentParsed(DocumentParsedEvent $event): void
{
$document = $event->getDocument();
$walker = $document->walker();
while ($event = $walker->next()) {
$node = $event->getNode();
if ($node instanceof FootnoteRef && $event->isEntering() && null !== $text = $node->getContent()) {
// Anonymous footnote needs to create a footnote from its content
$existingReference = $node->getReference();
$reference = new Reference(
$existingReference->getLabel(),
'#' . $this->config->get('footnote/ref_id_prefix', 'fnref:') . $existingReference->getLabel(),
$existingReference->getTitle()
);
$footnote = new Footnote($reference);
$footnote->addBackref(new FootnoteBackref($reference));
$paragraph = new Paragraph();
$paragraph->appendChild(new Text($text));
$footnote->appendChild($paragraph);
$document->appendChild($footnote);
foreach ($document->iterator() as $node) {
if (! $node instanceof FootnoteRef || ($text = $node->getContent()) === null) {
continue;
}
// Anonymous footnote needs to create a footnote from its content
$existingReference = $node->getReference();
$newReference = new Reference(
$existingReference->getLabel(),
'#' . $this->config->get('footnote/ref_id_prefix') . $existingReference->getLabel(),
$existingReference->getTitle()
);
$paragraph = new Paragraph();
$paragraph->appendChild(new Text($text));
$paragraph->appendChild(new FootnoteBackref($newReference));
$footnote = new Footnote($newReference);
$footnote->appendChild($paragraph);
$document->appendChild($footnote);
}
}
public function setConfiguration(ConfigurationInterface $config): void
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $config;
$this->config = $configuration;
}
}

View File

@@ -14,61 +14,43 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Event;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\Footnote\Node\Footnote;
use League\CommonMark\Extension\Footnote\Node\FootnoteBackref;
use League\CommonMark\Extension\Footnote\Node\FootnoteContainer;
use League\CommonMark\Node\Block\Document;
use League\CommonMark\Node\NodeIterator;
use League\CommonMark\Reference\Reference;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
final class GatherFootnotesListener implements ConfigurationAwareInterface
{
/** @var ConfigurationInterface */
private $config;
private ConfigurationInterface $config;
public function onDocumentParsed(DocumentParsedEvent $event): void
{
$document = $event->getDocument();
$walker = $document->walker();
$document = $event->getDocument();
$footnotes = [];
while ($event = $walker->next()) {
if (!$event->isEntering()) {
continue;
}
$node = $event->getNode();
if (!$node instanceof Footnote) {
foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
if (! $node instanceof Footnote) {
continue;
}
// Look for existing reference with footnote label
$ref = $document->getReferenceMap()->getReference($node->getReference()->getLabel());
$ref = $document->getReferenceMap()->get($node->getReference()->getLabel());
if ($ref !== null) {
// Use numeric title to get footnotes order
$footnotes[\intval($ref->getTitle())] = $node;
$footnotes[(int) $ref->getTitle()] = $node;
} else {
// Footnote call is missing, append footnote at the end
$footnotes[INF] = $node;
$footnotes[\PHP_INT_MAX] = $node;
}
/*
* Look for all footnote refs pointing to this footnote
* and create each footnote backrefs.
*/
$backrefs = $document->getData(
'#' . $this->config->get('footnote/footnote_id_prefix', 'fn:') . $node->getReference()->getDestination(),
[]
);
/** @var Reference $backref */
foreach ($backrefs as $backref) {
$node->addBackref(new FootnoteBackref(new Reference(
$backref->getLabel(),
'#' . $this->config->get('footnote/ref_id_prefix', 'fnref:') . $backref->getLabel(),
$backref->getTitle()
)));
$key = '#' . $this->config->get('footnote/footnote_id_prefix') . $node->getReference()->getDestination();
if ($document->data->has($key)) {
$this->createBackrefs($node, $document->data->get($key));
}
}
@@ -93,8 +75,32 @@ final class GatherFootnotesListener implements ConfigurationAwareInterface
return $footnoteContainer;
}
public function setConfiguration(ConfigurationInterface $config): void
/**
* Look for all footnote refs pointing to this footnote and create each footnote backrefs.
*
* @param Footnote $node The target footnote
* @param Reference[] $backrefs References to create backrefs for
*/
private function createBackrefs(Footnote $node, array $backrefs): void
{
$this->config = $config;
// Backrefs should be added to the child paragraph
$target = $node->lastChild();
if ($target === null) {
// This should never happen, but you never know
$target = $node;
}
foreach ($backrefs as $backref) {
$target->appendChild(new FootnoteBackref(new Reference(
$backref->getLabel(),
'#' . $this->config->get('footnote/ref_id_prefix') . $backref->getLabel(),
$backref->getTitle()
)));
}
}
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $configuration;
}
}

View File

@@ -22,26 +22,19 @@ final class NumberFootnotesListener
{
public function onDocumentParsed(DocumentParsedEvent $event): void
{
$document = $event->getDocument();
$walker = $document->walker();
$nextCounter = 1;
$usedLabels = [];
$document = $event->getDocument();
$nextCounter = 1;
$usedLabels = [];
$usedCounters = [];
while ($event = $walker->next()) {
if (!$event->isEntering()) {
foreach ($document->iterator() as $node) {
if (! $node instanceof FootnoteRef) {
continue;
}
$node = $event->getNode();
if (!$node instanceof FootnoteRef) {
continue;
}
$existingReference = $node->getReference();
$label = $existingReference->getLabel();
$counter = $nextCounter;
$existingReference = $node->getReference();
$label = $existingReference->getLabel();
$counter = $nextCounter;
$canIncrementCounter = true;
if (\array_key_exists($label, $usedLabels)) {
@@ -49,8 +42,8 @@ final class NumberFootnotesListener
* Reference is used again, we need to point
* to the same footnote. But with a different ID
*/
$counter = $usedCounters[$label];
$label = $label . '__' . ++$usedLabels[$label];
$counter = $usedCounters[$label];
$label .= '__' . ++$usedLabels[$label];
$canIncrementCounter = false;
}
@@ -63,19 +56,15 @@ final class NumberFootnotesListener
// Override reference with numeric link
$node->setReference($newReference);
$document->getReferenceMap()->addReference($newReference);
$document->getReferenceMap()->add($newReference);
/*
* Store created references in document for
* creating FootnoteBackrefs
*/
if (false === $document->getData($existingReference->getDestination(), false)) {
$document->data[$existingReference->getDestination()] = [];
}
$document->data->append($existingReference->getDestination(), $newReference);
$document->data[$existingReference->getDestination()][] = $newReference;
$usedLabels[$label] = 1;
$usedLabels[$label] = 1;
$usedCounters[$label] = $nextCounter;
if ($canIncrementCounter) {

View File

@@ -14,10 +14,11 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\CommonMark\Extension\Footnote\Event\AnonymousFootnotesListener;
use League\CommonMark\Extension\Footnote\Event\FixOrphanedFootnotesAndRefsListener;
use League\CommonMark\Extension\Footnote\Event\GatherFootnotesListener;
use League\CommonMark\Extension\Footnote\Event\NumberFootnotesListener;
use League\CommonMark\Extension\Footnote\Node\Footnote;
@@ -25,29 +26,45 @@ use League\CommonMark\Extension\Footnote\Node\FootnoteBackref;
use League\CommonMark\Extension\Footnote\Node\FootnoteContainer;
use League\CommonMark\Extension\Footnote\Node\FootnoteRef;
use League\CommonMark\Extension\Footnote\Parser\AnonymousFootnoteRefParser;
use League\CommonMark\Extension\Footnote\Parser\FootnoteParser;
use League\CommonMark\Extension\Footnote\Parser\FootnoteRefParser;
use League\CommonMark\Extension\Footnote\Parser\FootnoteStartParser;
use League\CommonMark\Extension\Footnote\Renderer\FootnoteBackrefRenderer;
use League\CommonMark\Extension\Footnote\Renderer\FootnoteContainerRenderer;
use League\CommonMark\Extension\Footnote\Renderer\FootnoteRefRenderer;
use League\CommonMark\Extension\Footnote\Renderer\FootnoteRenderer;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class FootnoteExtension implements ExtensionInterface
final class FootnoteExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$environment->addBlockParser(new FootnoteParser(), 51);
$builder->addSchema('footnote', Expect::structure([
'backref_class' => Expect::string('footnote-backref'),
'backref_symbol' => Expect::string('↩'),
'container_add_hr' => Expect::bool(true),
'container_class' => Expect::string('footnotes'),
'ref_class' => Expect::string('footnote-ref'),
'ref_id_prefix' => Expect::string('fnref:'),
'footnote_class' => Expect::string('footnote'),
'footnote_id_prefix' => Expect::string('fn:'),
]));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addBlockStartParser(new FootnoteStartParser(), 51);
$environment->addInlineParser(new AnonymousFootnoteRefParser(), 35);
$environment->addInlineParser(new FootnoteRefParser(), 51);
$environment->addBlockRenderer(FootnoteContainer::class, new FootnoteContainerRenderer());
$environment->addBlockRenderer(Footnote::class, new FootnoteRenderer());
$environment->addRenderer(FootnoteContainer::class, new FootnoteContainerRenderer());
$environment->addRenderer(Footnote::class, new FootnoteRenderer());
$environment->addRenderer(FootnoteRef::class, new FootnoteRefRenderer());
$environment->addRenderer(FootnoteBackref::class, new FootnoteBackrefRenderer());
$environment->addInlineRenderer(FootnoteRef::class, new FootnoteRefRenderer());
$environment->addInlineRenderer(FootnoteBackref::class, new FootnoteBackrefRenderer());
$environment->addEventListener(DocumentParsedEvent::class, [new AnonymousFootnotesListener(), 'onDocumentParsed']);
$environment->addEventListener(DocumentParsedEvent::class, [new NumberFootnotesListener(), 'onDocumentParsed']);
$environment->addEventListener(DocumentParsedEvent::class, [new GatherFootnotesListener(), 'onDocumentParsed']);
$environment->addEventListener(DocumentParsedEvent::class, [new AnonymousFootnotesListener(), 'onDocumentParsed'], 40);
$environment->addEventListener(DocumentParsedEvent::class, [new FixOrphanedFootnotesAndRefsListener(), 'onDocumentParsed'], 30);
$environment->addEventListener(DocumentParsedEvent::class, [new NumberFootnotesListener(), 'onDocumentParsed'], 20);
$environment->addEventListener(DocumentParsedEvent::class, [new GatherFootnotesListener(), 'onDocumentParsed'], 10);
}
}

View File

@@ -14,62 +14,24 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Node;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Cursor;
use League\CommonMark\Node\Block\AbstractBlock;
use League\CommonMark\Reference\ReferenceInterface;
use League\CommonMark\Reference\ReferenceableInterface;
/**
* @method children() AbstractBlock[]
*/
final class Footnote extends AbstractBlock
final class Footnote extends AbstractBlock implements ReferenceableInterface
{
/**
* @var FootnoteBackref[]
*/
private $backrefs = [];
/**
* @var ReferenceInterface
*/
private $reference;
/** @psalm-readonly */
private ReferenceInterface $reference;
public function __construct(ReferenceInterface $reference)
{
parent::__construct();
$this->reference = $reference;
}
public function canContain(AbstractBlock $block): bool
{
return true;
}
public function isCode(): bool
{
return false;
}
public function matchesNextLine(Cursor $cursor): bool
{
return false;
}
public function getReference(): ReferenceInterface
{
return $this->reference;
}
public function addBackref(FootnoteBackref $backref): self
{
$this->backrefs[] = $backref;
return $this;
}
/**
* @return FootnoteBackref[]
*/
public function getBackrefs(): array
{
return $this->backrefs;
}
}

View File

@@ -14,19 +14,22 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Node;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Inline\AbstractInline;
use League\CommonMark\Reference\ReferenceInterface;
use League\CommonMark\Reference\ReferenceableInterface;
/**
* Link from the footnote on the bottom of the document back to the reference
*/
final class FootnoteBackref extends AbstractInline
final class FootnoteBackref extends AbstractInline implements ReferenceableInterface
{
/** @var ReferenceInterface */
private $reference;
/** @psalm-readonly */
private ReferenceInterface $reference;
public function __construct(ReferenceInterface $reference)
{
parent::__construct();
$this->reference = $reference;
}

View File

@@ -14,26 +14,8 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Node;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Cursor;
use League\CommonMark\Node\Block\AbstractBlock;
/**
* @method children() AbstractBlock[]
*/
final class FootnoteContainer extends AbstractBlock
{
public function canContain(AbstractBlock $block): bool
{
return $block instanceof Footnote;
}
public function isCode(): bool
{
return false;
}
public function matchesNextLine(Cursor $cursor): bool
{
return false;
}
}

View File

@@ -14,27 +14,30 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Node;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Inline\AbstractInline;
use League\CommonMark\Reference\ReferenceInterface;
use League\CommonMark\Reference\ReferenceableInterface;
final class FootnoteRef extends AbstractInline
final class FootnoteRef extends AbstractInline implements ReferenceableInterface
{
/** @var ReferenceInterface */
private $reference;
private ReferenceInterface $reference;
/** @var string|null */
private $content;
/** @psalm-readonly */
private ?string $content = null;
/**
* @param ReferenceInterface $reference
* @param string|null $content
* @param array<mixed> $data
* @param array<mixed> $data
*/
public function __construct(ReferenceInterface $reference, ?string $content = null, array $data = [])
{
parent::__construct();
$this->reference = $reference;
$this->content = $content;
$this->data = $data;
$this->content = $content;
if (\count($data) > 0) {
$this->data->import($data);
}
}
public function getReference(): ReferenceInterface
@@ -42,11 +45,9 @@ final class FootnoteRef extends AbstractInline
return $this->reference;
}
public function setReference(ReferenceInterface $reference): FootnoteRef
public function setReference(ReferenceInterface $reference): void
{
$this->reference = $reference;
return $this;
}
public function getContent(): ?string

View File

@@ -14,72 +14,53 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Parser;
use League\CommonMark\Environment\EnvironmentAwareInterface;
use League\CommonMark\Environment\EnvironmentInterface;
use League\CommonMark\Extension\Footnote\Node\FootnoteRef;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\InlineParserContext;
use League\CommonMark\Normalizer\SlugNormalizer;
use League\CommonMark\Normalizer\TextNormalizerInterface;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
use League\CommonMark\Reference\Reference;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
use League\Config\ConfigurationInterface;
final class AnonymousFootnoteRefParser implements InlineParserInterface, ConfigurationAwareInterface
final class AnonymousFootnoteRefParser implements InlineParserInterface, EnvironmentAwareInterface
{
/** @var ConfigurationInterface */
private $config;
private ConfigurationInterface $config;
/** @var TextNormalizerInterface */
private $slugNormalizer;
/** @psalm-readonly-allow-private-mutation */
private TextNormalizerInterface $slugNormalizer;
public function __construct()
public function getMatchDefinition(): InlineParserMatch
{
$this->slugNormalizer = new SlugNormalizer();
}
public function getCharacters(): array
{
return ['^'];
return InlineParserMatch::regex('\^\[([^\]]+)\]');
}
public function parse(InlineParserContext $inlineContext): bool
{
$container = $inlineContext->getContainer();
$cursor = $inlineContext->getCursor();
$nextChar = $cursor->peek();
if ($nextChar !== '[') {
return false;
}
$state = $cursor->saveState();
$inlineContext->getCursor()->advanceBy($inlineContext->getFullMatchLength());
$m = $cursor->match('/\^\[[^\n^\]]+\]/');
if ($m !== null) {
if (\preg_match('#\^\[([^\]]+)\]#', $m, $matches) > 0) {
$reference = $this->createReference($matches[1]);
$container->appendChild(new FootnoteRef($reference, $matches[1]));
[$label] = $inlineContext->getSubMatches();
$reference = $this->createReference($label);
$inlineContext->getContainer()->appendChild(new FootnoteRef($reference, $label));
return true;
}
}
$cursor->restoreState($state);
return false;
return true;
}
private function createReference(string $label): Reference
{
$refLabel = $this->slugNormalizer->normalize($label);
$refLabel = \mb_substr($refLabel, 0, 20);
$refLabel = $this->slugNormalizer->normalize($label, ['length' => 20]);
return new Reference(
$refLabel,
'#' . $this->config->get('footnote/footnote_id_prefix', 'fn:') . $refLabel,
'#' . $this->config->get('footnote/footnote_id_prefix') . $refLabel,
$label
);
}
public function setConfiguration(ConfigurationInterface $config): void
public function setEnvironment(EnvironmentInterface $environment): void
{
$this->config = $config;
$this->config = $environment->getConfiguration();
$this->slugNormalizer = $environment->getSlugNormalizer();
}
}

View File

@@ -14,50 +14,55 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Parser;
use League\CommonMark\Block\Parser\BlockParserInterface;
use League\CommonMark\ContextInterface;
use League\CommonMark\Cursor;
use League\CommonMark\Extension\Footnote\Node\Footnote;
use League\CommonMark\Reference\Reference;
use League\CommonMark\Util\RegexHelper;
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;
use League\CommonMark\Reference\ReferenceInterface;
final class FootnoteParser implements BlockParserInterface
final class FootnoteParser extends AbstractBlockContinueParser
{
public function parse(ContextInterface $context, Cursor $cursor): bool
/** @psalm-readonly */
private Footnote $block;
/** @psalm-readonly-allow-private-mutation */
private ?int $indentation = null;
public function __construct(ReferenceInterface $reference)
{
if ($cursor->isIndented()) {
return false;
}
$match = RegexHelper::matchFirst(
'/^\[\^([^\n^\]]+)\]\:\s/',
$cursor->getLine(),
$cursor->getNextNonSpacePosition()
);
if (!$match) {
return false;
}
$cursor->advanceToNextNonSpaceOrTab();
$cursor->advanceBy(\strlen($match[0]));
$str = $cursor->getRemainder();
\preg_replace('/^\[\^([^\n^\]]+)\]\:\s/', '', $str);
if (\preg_match('/^\[\^([^\n^\]]+)\]\:\s/', $match[0], $matches) > 0) {
$context->addBlock($this->createFootnote($matches[1]));
$context->setBlocksParsed(true);
return true;
}
return false;
$this->block = new Footnote($reference);
}
private function createFootnote(string $label): Footnote
public function getBlock(): Footnote
{
return new Footnote(
new Reference($label, $label, $label)
);
return $this->block;
}
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
{
if ($cursor->isBlank()) {
return BlockContinue::at($cursor);
}
if ($cursor->isIndented()) {
$this->indentation ??= $cursor->getIndent();
$cursor->advanceBy($this->indentation, true);
return BlockContinue::at($cursor);
}
return BlockContinue::none();
}
public function isContainer(): bool
{
return true;
}
public function canContain(AbstractBlock $childBlock): bool
{
return true;
}
}

View File

@@ -15,58 +15,43 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Parser;
use League\CommonMark\Extension\Footnote\Node\FootnoteRef;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\InlineParserContext;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
use League\CommonMark\Reference\Reference;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
final class FootnoteRefParser implements InlineParserInterface, ConfigurationAwareInterface
{
/** @var ConfigurationInterface */
private $config;
private ConfigurationInterface $config;
public function getCharacters(): array
public function getMatchDefinition(): InlineParserMatch
{
return ['['];
return InlineParserMatch::regex('\[\^([^\s\]]+)\]');
}
public function parse(InlineParserContext $inlineContext): bool
{
$container = $inlineContext->getContainer();
$cursor = $inlineContext->getCursor();
$nextChar = $cursor->peek();
if ($nextChar !== '^') {
return false;
}
$inlineContext->getCursor()->advanceBy($inlineContext->getFullMatchLength());
$state = $cursor->saveState();
[$label] = $inlineContext->getSubMatches();
$inlineContext->getContainer()->appendChild(new FootnoteRef($this->createReference($label)));
$m = $cursor->match('#\[\^([^\]]+)\]#');
if ($m !== null) {
if (\preg_match('#\[\^([^\]]+)\]#', $m, $matches) > 0) {
$container->appendChild(new FootnoteRef($this->createReference($matches[1])));
return true;
}
}
$cursor->restoreState($state);
return false;
return true;
}
private function createReference(string $label): Reference
{
return new Reference(
$label,
'#' . $this->config->get('footnote/footnote_id_prefix', 'fn:') . $label,
'#' . $this->config->get('footnote/footnote_id_prefix') . $label,
$label
);
}
public function setConfiguration(ConfigurationInterface $config): void
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $config;
$this->config = $configuration;
}
}

View File

@@ -14,36 +14,68 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Renderer;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\Footnote\Node\FootnoteBackref;
use League\CommonMark\HtmlElement;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
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;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
final class FootnoteBackrefRenderer implements InlineRendererInterface, ConfigurationAwareInterface
final class FootnoteBackrefRenderer implements NodeRendererInterface, XmlNodeRendererInterface, ConfigurationAwareInterface
{
/** @var ConfigurationInterface */
private $config;
public const DEFAULT_SYMBOL = '↩';
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
private ConfigurationInterface $config;
/**
* @param FootnoteBackref $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
if (!($inline instanceof FootnoteBackref)) {
throw new \InvalidArgumentException('Incompatible inline type: ' . \get_class($inline));
}
FootnoteBackref::assertInstanceOf($node);
$attrs = $inline->getData('attributes', []);
$attrs['class'] = $attrs['class'] ?? $this->config->get('footnote/backref_class', 'footnote-backref');
$attrs['rev'] = 'footnote';
$attrs['href'] = \mb_strtolower($inline->getReference()->getDestination());
$attrs['role'] = 'doc-backlink';
$attrs = $node->data->getData('attributes');
return '&nbsp;' . new HtmlElement('a', $attrs, '&#8617;', true);
$attrs->append('class', $this->config->get('footnote/backref_class'));
$attrs->set('rev', 'footnote');
$attrs->set('href', \mb_strtolower($node->getReference()->getDestination(), 'UTF-8'));
$attrs->set('role', 'doc-backlink');
$symbol = $this->config->get('footnote/backref_symbol');
\assert(\is_string($symbol));
return '&nbsp;' . new HtmlElement('a', $attrs->export(), \htmlspecialchars($symbol), true);
}
public function setConfiguration(ConfigurationInterface $configuration)
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $configuration;
}
public function getXmlTagName(Node $node): string
{
return 'footnote_backref';
}
/**
* @param FootnoteBackref $node
*
* @return array<string, scalar>
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function getXmlAttributes(Node $node): array
{
FootnoteBackref::assertInstanceOf($node);
return [
'reference' => $node->getReference()->getLabel(),
];
}
}

View File

@@ -14,39 +14,58 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Renderer;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\Footnote\Node\FootnoteContainer;
use League\CommonMark\HtmlElement;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
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;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
final class FootnoteContainerRenderer implements BlockRendererInterface, ConfigurationAwareInterface
final class FootnoteContainerRenderer implements NodeRendererInterface, XmlNodeRendererInterface, ConfigurationAwareInterface
{
/** @var ConfigurationInterface */
private $config;
private ConfigurationInterface $config;
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
/**
* @param FootnoteContainer $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
if (!($block instanceof FootnoteContainer)) {
throw new \InvalidArgumentException('Incompatible block type: ' . \get_class($block));
}
FootnoteContainer::assertInstanceOf($node);
$attrs = $block->getData('attributes', []);
$attrs['class'] = $attrs['class'] ?? $this->config->get('footnote/container_class', 'footnotes');
$attrs['role'] = 'doc-endnotes';
$attrs = $node->data->getData('attributes');
$contents = new HtmlElement('ol', [], $htmlRenderer->renderBlocks($block->children()));
if ($this->config->get('footnote/container_add_hr', true)) {
$attrs->append('class', $this->config->get('footnote/container_class'));
$attrs->set('role', 'doc-endnotes');
$contents = new HtmlElement('ol', [], $childRenderer->renderNodes($node->children()));
if ($this->config->get('footnote/container_add_hr')) {
$contents = [new HtmlElement('hr', [], null, true), $contents];
}
return new HtmlElement('div', $attrs, $contents);
return new HtmlElement('div', $attrs->export(), $contents);
}
public function setConfiguration(ConfigurationInterface $configuration)
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $configuration;
}
public function getXmlTagName(Node $node): string
{
return 'footnote_container';
}
/**
* @return array<string, scalar>
*/
public function getXmlAttributes(Node $node): array
{
return [];
}
}

View File

@@ -14,49 +14,74 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Renderer;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\Footnote\Node\FootnoteRef;
use League\CommonMark\HtmlElement;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
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;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
final class FootnoteRefRenderer implements InlineRendererInterface, ConfigurationAwareInterface
final class FootnoteRefRenderer implements NodeRendererInterface, XmlNodeRendererInterface, ConfigurationAwareInterface
{
/** @var ConfigurationInterface */
private $config;
private ConfigurationInterface $config;
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
/**
* @param FootnoteRef $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
if (!($inline instanceof FootnoteRef)) {
throw new \InvalidArgumentException('Incompatible inline type: ' . \get_class($inline));
}
FootnoteRef::assertInstanceOf($node);
$attrs = $inline->getData('attributes', []);
$class = $attrs['class'] ?? $this->config->get('footnote/ref_class', 'footnote-ref');
$idPrefix = $this->config->get('footnote/ref_id_prefix', 'fnref:');
$attrs = $node->data->getData('attributes');
$attrs->append('class', $this->config->get('footnote/ref_class'));
$attrs->set('href', \mb_strtolower($node->getReference()->getDestination(), 'UTF-8'));
$attrs->set('role', 'doc-noteref');
$idPrefix = $this->config->get('footnote/ref_id_prefix');
return new HtmlElement(
'sup',
[
'id' => $idPrefix . \mb_strtolower($inline->getReference()->getLabel()),
'id' => $idPrefix . \mb_strtolower($node->getReference()->getLabel(), 'UTF-8'),
],
new HTMLElement(
new HtmlElement(
'a',
[
'class' => $class,
'href' => \mb_strtolower($inline->getReference()->getDestination()),
'role' => 'doc-noteref',
],
$inline->getReference()->getTitle()
$attrs->export(),
$node->getReference()->getTitle()
),
true
);
}
public function setConfiguration(ConfigurationInterface $configuration)
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $configuration;
}
public function getXmlTagName(Node $node): string
{
return 'footnote_ref';
}
/**
* @param FootnoteRef $node
*
* @return array<string, scalar>
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function getXmlAttributes(Node $node): array
{
FootnoteRef::assertInstanceOf($node);
return [
'reference' => $node->getReference()->getLabel(),
];
}
}

View File

@@ -14,51 +14,67 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Footnote\Renderer;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Extension\Footnote\Node\Footnote;
use League\CommonMark\HtmlElement;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
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;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
final class FootnoteRenderer implements BlockRendererInterface, ConfigurationAwareInterface
final class FootnoteRenderer implements NodeRendererInterface, XmlNodeRendererInterface, ConfigurationAwareInterface
{
/** @var ConfigurationInterface */
private $config;
private ConfigurationInterface $config;
/**
* @param Footnote $block
* @param ElementRendererInterface $htmlRenderer
* @param bool $inTightList
* @param Footnote $node
*
* @return HtmlElement
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
if (!($block instanceof Footnote)) {
throw new \InvalidArgumentException('Incompatible block type: ' . \get_class($block));
}
Footnote::assertInstanceOf($node);
$attrs = $block->getData('attributes', []);
$attrs['class'] = $attrs['class'] ?? $this->config->get('footnote/footnote_class', 'footnote');
$attrs['id'] = $this->config->get('footnote/footnote_id_prefix', 'fn:') . \mb_strtolower($block->getReference()->getLabel());
$attrs['role'] = 'doc-endnote';
$attrs = $node->data->getData('attributes');
foreach ($block->getBackrefs() as $backref) {
$block->lastChild()->appendChild($backref);
}
$attrs->append('class', $this->config->get('footnote/footnote_class'));
$attrs->set('id', $this->config->get('footnote/footnote_id_prefix') . \mb_strtolower($node->getReference()->getLabel(), 'UTF-8'));
$attrs->set('role', 'doc-endnote');
return new HtmlElement(
'li',
$attrs,
$htmlRenderer->renderBlocks($block->children()),
$attrs->export(),
$childRenderer->renderNodes($node->children()),
true
);
}
public function setConfiguration(ConfigurationInterface $configuration)
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $configuration;
}
public function getXmlTagName(Node $node): string
{
return 'footnote';
}
/**
* @param Footnote $node
*
* @return array<string, scalar>
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function getXmlAttributes(Node $node): array
{
Footnote::assertInstanceOf($node);
return [
'reference' => $node->getReference()->getLabel(),
];
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,7 +13,7 @@
namespace League\CommonMark\Extension;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
@@ -20,7 +22,7 @@ use League\CommonMark\Extension\TaskList\TaskListExtension;
final class GithubFlavoredMarkdownExtension implements ExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,18 +13,20 @@
namespace League\CommonMark\Extension\HeadingPermalink;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Inline\AbstractInline;
/**
* Represents an anchor link within a heading
*/
final class HeadingPermalink extends AbstractInline
{
/** @var string */
private $slug;
/** @psalm-readonly */
private string $slug;
public function __construct(string $slug)
{
parent::__construct();
$this->slug = $slug;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,18 +13,37 @@
namespace League\CommonMark\Extension\HeadingPermalink;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
/**
* Extension which automatically anchor links to heading elements
*/
final class HeadingPermalinkExtension implements ExtensionInterface
final class HeadingPermalinkExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$builder->addSchema('heading_permalink', Expect::structure([
'min_heading_level' => Expect::int()->min(1)->max(6)->default(1),
'max_heading_level' => Expect::int()->min(1)->max(6)->default(6),
'insert' => Expect::anyOf(HeadingPermalinkProcessor::INSERT_BEFORE, HeadingPermalinkProcessor::INSERT_AFTER, HeadingPermalinkProcessor::INSERT_NONE)->default(HeadingPermalinkProcessor::INSERT_BEFORE),
'id_prefix' => Expect::string()->default('content'),
'apply_id_to_heading' => Expect::bool()->default(false),
'heading_class' => Expect::string()->default(''),
'fragment_prefix' => Expect::string()->default('content'),
'html_class' => Expect::string()->default('heading-permalink'),
'title' => Expect::string()->default('Permalink'),
'symbol' => Expect::string()->default(HeadingPermalinkRenderer::DEFAULT_SYMBOL),
'aria_hidden' => Expect::bool()->default(true),
]));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addEventListener(DocumentParsedEvent::class, new HeadingPermalinkProcessor(), -100);
$environment->addInlineRenderer(HeadingPermalink::class, new HeadingPermalinkRenderer());
$environment->addRenderer(HeadingPermalink::class, new HeadingPermalinkRenderer());
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,91 +13,77 @@
namespace League\CommonMark\Extension\HeadingPermalink;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Block\Element\Heading;
use League\CommonMark\Environment\EnvironmentAwareInterface;
use League\CommonMark\Environment\EnvironmentInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Exception\InvalidOptionException;
use League\CommonMark\Extension\HeadingPermalink\Slug\SlugGeneratorInterface as DeprecatedSlugGeneratorInterface;
use League\CommonMark\Inline\Element\AbstractStringContainer;
use League\CommonMark\Node\Node;
use League\CommonMark\Normalizer\SlugNormalizer;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
use League\CommonMark\Node\NodeIterator;
use League\CommonMark\Node\RawMarkupContainerInterface;
use League\CommonMark\Node\StringContainerHelper;
use League\CommonMark\Normalizer\TextNormalizerInterface;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
use League\Config\ConfigurationInterface;
use League\Config\Exception\InvalidConfigurationException;
/**
* Searches the Document for Heading elements and adds HeadingPermalinks to each one
*/
final class HeadingPermalinkProcessor implements ConfigurationAwareInterface
final class HeadingPermalinkProcessor implements EnvironmentAwareInterface
{
const INSERT_BEFORE = 'before';
const INSERT_AFTER = 'after';
public const INSERT_BEFORE = 'before';
public const INSERT_AFTER = 'after';
public const INSERT_NONE = 'none';
/** @var TextNormalizerInterface|DeprecatedSlugGeneratorInterface */
private $slugNormalizer;
/** @psalm-readonly-allow-private-mutation */
private TextNormalizerInterface $slugNormalizer;
/** @var ConfigurationInterface */
private $config;
/** @psalm-readonly-allow-private-mutation */
private ConfigurationInterface $config;
/**
* @param TextNormalizerInterface|DeprecatedSlugGeneratorInterface|null $slugNormalizer
*/
public function __construct($slugNormalizer = null)
public function setEnvironment(EnvironmentInterface $environment): void
{
if ($slugNormalizer instanceof DeprecatedSlugGeneratorInterface) {
@trigger_error(sprintf('Passing a %s into the %s constructor is deprecated; use a %s instead', DeprecatedSlugGeneratorInterface::class, self::class, TextNormalizerInterface::class), E_USER_DEPRECATED);
}
$this->slugNormalizer = $slugNormalizer ?? new SlugNormalizer();
}
public function setConfiguration(ConfigurationInterface $configuration)
{
$this->config = $configuration;
$this->config = $environment->getConfiguration();
$this->slugNormalizer = $environment->getSlugNormalizer();
}
public function __invoke(DocumentParsedEvent $e): void
{
$this->useNormalizerFromConfigurationIfProvided();
$min = (int) $this->config->get('heading_permalink/min_heading_level');
$max = (int) $this->config->get('heading_permalink/max_heading_level');
$applyToHeading = (bool) $this->config->get('heading_permalink/apply_id_to_heading');
$idPrefix = (string) $this->config->get('heading_permalink/id_prefix');
$slugLength = (int) $this->config->get('slug_normalizer/max_length');
$headingClass = (string) $this->config->get('heading_permalink/heading_class');
$walker = $e->getDocument()->walker();
if ($idPrefix !== '') {
$idPrefix .= '-';
}
while ($event = $walker->next()) {
$node = $event->getNode();
if ($node instanceof Heading && $event->isEntering()) {
$this->addHeadingLink($node, $e->getDocument());
foreach ($e->getDocument()->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
if ($node instanceof Heading && $node->getLevel() >= $min && $node->getLevel() <= $max) {
$this->addHeadingLink($node, $slugLength, $idPrefix, $applyToHeading, $headingClass);
}
}
}
private function useNormalizerFromConfigurationIfProvided(): void
private function addHeadingLink(Heading $heading, int $slugLength, string $idPrefix, bool $applyToHeading, string $headingClass): void
{
$generator = $this->config->get('heading_permalink/slug_normalizer');
if ($generator === null) {
return;
$text = StringContainerHelper::getChildText($heading, [RawMarkupContainerInterface::class]);
$slug = $this->slugNormalizer->normalize($text, [
'node' => $heading,
'length' => $slugLength,
]);
if ($applyToHeading) {
$heading->data->set('attributes/id', $idPrefix . $slug);
}
if (!($generator instanceof DeprecatedSlugGeneratorInterface || $generator instanceof TextNormalizerInterface)) {
throw new InvalidOptionException('The heading_permalink/slug_normalizer option must be an instance of ' . TextNormalizerInterface::class);
if ($headingClass !== '') {
$heading->data->append('attributes/class', $headingClass);
}
$this->slugNormalizer = $generator;
}
private function addHeadingLink(Heading $heading, Document $document): void
{
$text = $this->getChildText($heading);
if ($this->slugNormalizer instanceof DeprecatedSlugGeneratorInterface) {
$slug = $this->slugNormalizer->createSlug($text);
} else {
$slug = $this->slugNormalizer->normalize($text, $heading);
}
$slug = $this->ensureUnique($slug, $document);
$headingLinkAnchor = new HeadingPermalink($slug);
switch ($this->config->get('heading_permalink/insert', 'before')) {
switch ($this->config->get('heading_permalink/insert')) {
case self::INSERT_BEFORE:
$heading->prependChild($headingLinkAnchor);
@@ -103,45 +91,11 @@ final class HeadingPermalinkProcessor implements ConfigurationAwareInterface
case self::INSERT_AFTER:
$heading->appendChild($headingLinkAnchor);
return;
case self::INSERT_NONE:
return;
default:
throw new \RuntimeException("Invalid configuration value for heading_permalink/insert; expected 'before' or 'after'");
throw new InvalidConfigurationException("Invalid configuration value for heading_permalink/insert; expected 'before', 'after', or 'none'");
}
}
/**
* @deprecated Not needed in 2.0
*/
private function getChildText(Node $node): string
{
$text = '';
$walker = $node->walker();
while ($event = $walker->next()) {
if ($event->isEntering() && (($child = $event->getNode()) instanceof AbstractStringContainer)) {
$text .= $child->getContent();
}
}
return $text;
}
private function ensureUnique(string $proposed, Document $document): string
{
// Quick path, it's a unique ID
if (!isset($document->data['heading_ids'][$proposed])) {
$document->data['heading_ids'][$proposed] = true;
return $proposed;
}
$extension = 0;
do {
++$extension;
} while (isset($document->data['heading_ids']["$proposed-$extension"]));
$document->data['heading_ids']["$proposed-$extension"] = true;
return "$proposed-$extension";
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,62 +13,94 @@
namespace League\CommonMark\Extension\HeadingPermalink;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\HtmlElement;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
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;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
/**
* Renders the HeadingPermalink elements
*/
final class HeadingPermalinkRenderer implements InlineRendererInterface, ConfigurationAwareInterface
final class HeadingPermalinkRenderer implements NodeRendererInterface, XmlNodeRendererInterface, ConfigurationAwareInterface
{
/** @deprecated */
const DEFAULT_INNER_CONTENTS = '<svg class="heading-permalink-icon" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>';
public const DEFAULT_SYMBOL = '¶';
const DEFAULT_SYMBOL = '¶';
/** @psalm-readonly-allow-private-mutation */
private ConfigurationInterface $config;
/** @var ConfigurationInterface */
private $config;
public function setConfiguration(ConfigurationInterface $configuration)
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $configuration;
}
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
/**
* @param HeadingPermalink $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
if (!$inline instanceof HeadingPermalink) {
throw new \InvalidArgumentException('Incompatible inline type: ' . \get_class($inline));
HeadingPermalink::assertInstanceOf($node);
$slug = $node->getSlug();
$fragmentPrefix = (string) $this->config->get('heading_permalink/fragment_prefix');
if ($fragmentPrefix !== '') {
$fragmentPrefix .= '-';
}
$slug = $inline->getSlug();
$attrs = $node->data->getData('attributes');
$appendId = ! $this->config->get('heading_permalink/apply_id_to_heading');
$idPrefix = (string) $this->config->get('heading_permalink/id_prefix', 'user-content');
if ($idPrefix !== '') {
$idPrefix .= '-';
if ($appendId) {
$idPrefix = (string) $this->config->get('heading_permalink/id_prefix');
if ($idPrefix !== '') {
$idPrefix .= '-';
}
$attrs->set('id', $idPrefix . $slug);
}
$attrs = [
'id' => $idPrefix . $slug,
'href' => '#' . $slug,
'name' => $slug,
'class' => $this->config->get('heading_permalink/html_class', 'heading-permalink'),
'aria-hidden' => 'true',
'title' => $this->config->get('heading_permalink/title', 'Permalink'),
$attrs->set('href', '#' . $fragmentPrefix . $slug);
$attrs->append('class', $this->config->get('heading_permalink/html_class'));
$hidden = $this->config->get('heading_permalink/aria_hidden');
if ($hidden) {
$attrs->set('aria-hidden', 'true');
}
$attrs->set('title', $this->config->get('heading_permalink/title'));
$symbol = $this->config->get('heading_permalink/symbol');
\assert(\is_string($symbol));
return new HtmlElement('a', $attrs->export(), \htmlspecialchars($symbol), false);
}
public function getXmlTagName(Node $node): string
{
return 'heading_permalink';
}
/**
* @param HeadingPermalink $node
*
* @return array<string, scalar>
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function getXmlAttributes(Node $node): array
{
HeadingPermalink::assertInstanceOf($node);
return [
'slug' => $node->getSlug(),
];
$innerContents = $this->config->get('heading_permalink/inner_contents');
if ($innerContents !== null) {
@trigger_error(sprintf('The %s config option is deprecated; use %s instead', 'inner_contents', 'symbol'), E_USER_DEPRECATED);
return new HtmlElement('a', $attrs, $innerContents, false);
}
$symbol = $this->config->get('heading_permalink/symbol', self::DEFAULT_SYMBOL);
return new HtmlElement('a', $attrs, \htmlspecialchars($symbol), false);
}
}

View File

@@ -1,38 +0,0 @@
<?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.
*/
namespace League\CommonMark\Extension\HeadingPermalink\Slug;
use League\CommonMark\Normalizer\SlugNormalizer;
@trigger_error(sprintf('%s is deprecated; use %s instead', DefaultSlugGenerator::class, SlugNormalizer::class), E_USER_DEPRECATED);
/**
* Creates URL-friendly strings
*
* @deprecated Use League\CommonMark\Normalizer\SlugNormalizer instead
*/
final class DefaultSlugGenerator implements SlugGeneratorInterface
{
public function createSlug(string $input): string
{
// Trim whitespace
$slug = \trim($input);
// Convert to lowercase
$slug = \mb_strtolower($slug);
// 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;
return $slug;
}
}

View File

@@ -1,31 +0,0 @@
<?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.
*/
namespace League\CommonMark\Extension\HeadingPermalink\Slug;
use League\CommonMark\Normalizer\TextNormalizerInterface;
@trigger_error(sprintf('%s is deprecated; use %s instead', SlugGeneratorInterface::class, TextNormalizerInterface::class), E_USER_DEPRECATED);
/**
* @deprecated Use League\CommonMark\Normalizer\TextNormalizerInterface instead
*/
interface SlugGeneratorInterface
{
/**
* Create a URL-friendly slug based on the given input string
*
* @param string $input
*
* @return string
*/
public function createSlug(string $input): string;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,34 +13,21 @@
namespace League\CommonMark\Extension\InlinesOnly;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Block\Element\InlineContainerInterface;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Block\Document;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
/**
* Simply renders child elements as-is, adding newlines as needed.
*/
final class ChildRenderer implements BlockRendererInterface
final class ChildRenderer implements NodeRendererInterface
{
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
$out = '';
if ($block instanceof InlineContainerInterface) {
/** @var iterable<AbstractInline> $children */
$children = $block->children();
$out .= $htmlRenderer->renderInlines($children);
} else {
/** @var iterable<AbstractBlock> $children */
$children = $block->children();
$out .= $htmlRenderer->renderBlocks($children);
}
if (!($block instanceof Document)) {
$out .= "\n";
$out = $childRenderer->renderNodes($node->children());
if (! $node instanceof Document) {
$out .= $childRenderer->getBlockSeparator();
}
return $out;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,52 +13,60 @@
namespace League\CommonMark\Extension\InlinesOnly;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\Block\Parser as BlockParser;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Delimiter\Processor\EmphasisDelimiterProcessor;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Inline\Element as InlineElement;
use League\CommonMark\Inline\Parser as InlineParser;
use League\CommonMark\Inline\Renderer as InlineRenderer;
use League\CommonMark as Core;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\CommonMark;
use League\CommonMark\Extension\CommonMark\Delimiter\Processor\EmphasisDelimiterProcessor;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class InlinesOnlyExtension implements ExtensionInterface
final class InlinesOnlyExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$builder->addSchema('commonmark', Expect::structure([
'use_asterisk' => Expect::bool(true),
'use_underscore' => Expect::bool(true),
'enable_strong' => Expect::bool(true),
'enable_em' => Expect::bool(true),
]));
}
// phpcs:disable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma,Squiz.WhiteSpace.SemicolonSpacing.Incorrect
public function register(EnvironmentBuilderInterface $environment): void
{
$childRenderer = new ChildRenderer();
$environment
->addBlockParser(new BlockParser\LazyParagraphParser(), -200)
->addInlineParser(new Core\Parser\Inline\NewlineParser(), 200)
->addInlineParser(new CommonMark\Parser\Inline\BacktickParser(), 150)
->addInlineParser(new CommonMark\Parser\Inline\EscapableParser(), 80)
->addInlineParser(new CommonMark\Parser\Inline\EntityParser(), 70)
->addInlineParser(new CommonMark\Parser\Inline\AutolinkParser(), 50)
->addInlineParser(new CommonMark\Parser\Inline\HtmlInlineParser(), 40)
->addInlineParser(new CommonMark\Parser\Inline\CloseBracketParser(), 30)
->addInlineParser(new CommonMark\Parser\Inline\OpenBracketParser(), 20)
->addInlineParser(new CommonMark\Parser\Inline\BangParser(), 10)
->addInlineParser(new InlineParser\NewlineParser(), 200)
->addInlineParser(new InlineParser\BacktickParser(), 150)
->addInlineParser(new InlineParser\EscapableParser(), 80)
->addInlineParser(new InlineParser\EntityParser(), 70)
->addInlineParser(new InlineParser\AutolinkParser(), 50)
->addInlineParser(new InlineParser\HtmlInlineParser(), 40)
->addInlineParser(new InlineParser\CloseBracketParser(), 30)
->addInlineParser(new InlineParser\OpenBracketParser(), 20)
->addInlineParser(new InlineParser\BangParser(), 10)
->addRenderer(Core\Node\Block\Document::class, $childRenderer, 0)
->addRenderer(Core\Node\Block\Paragraph::class, $childRenderer, 0)
->addBlockRenderer(Document::class, $childRenderer, 0)
->addBlockRenderer(Paragraph::class, $childRenderer, 0)
->addInlineRenderer(InlineElement\Code::class, new InlineRenderer\CodeRenderer(), 0)
->addInlineRenderer(InlineElement\Emphasis::class, new InlineRenderer\EmphasisRenderer(), 0)
->addInlineRenderer(InlineElement\HtmlInline::class, new InlineRenderer\HtmlInlineRenderer(), 0)
->addInlineRenderer(InlineElement\Image::class, new InlineRenderer\ImageRenderer(), 0)
->addInlineRenderer(InlineElement\Link::class, new InlineRenderer\LinkRenderer(), 0)
->addInlineRenderer(InlineElement\Newline::class, new InlineRenderer\NewlineRenderer(), 0)
->addInlineRenderer(InlineElement\Strong::class, new InlineRenderer\StrongRenderer(), 0)
->addInlineRenderer(InlineElement\Text::class, new InlineRenderer\TextRenderer(), 0)
->addRenderer(CommonMark\Node\Inline\Code::class, new CommonMark\Renderer\Inline\CodeRenderer(), 0)
->addRenderer(CommonMark\Node\Inline\Emphasis::class, new CommonMark\Renderer\Inline\EmphasisRenderer(), 0)
->addRenderer(CommonMark\Node\Inline\HtmlInline::class, new CommonMark\Renderer\Inline\HtmlInlineRenderer(), 0)
->addRenderer(CommonMark\Node\Inline\Image::class, new CommonMark\Renderer\Inline\ImageRenderer(), 0)
->addRenderer(CommonMark\Node\Inline\Link::class, new CommonMark\Renderer\Inline\LinkRenderer(), 0)
->addRenderer(Core\Node\Inline\Newline::class, new Core\Renderer\Inline\NewlineRenderer(), 0)
->addRenderer(CommonMark\Node\Inline\Strong::class, new CommonMark\Renderer\Inline\StrongRenderer(), 0)
->addRenderer(Core\Node\Inline\Text::class, new Core\Renderer\Inline\TextRenderer(), 0)
;
if ($environment->getConfig('use_asterisk', true)) {
if ($environment->getConfiguration()->get('commonmark/use_asterisk')) {
$environment->addDelimiterProcessor(new EmphasisDelimiterProcessor('*'));
}
if ($environment->getConfig('use_underscore', true)) {
if ($environment->getConfiguration()->get('commonmark/use_underscore')) {
$environment->addDelimiterProcessor(new EmphasisDelimiterProcessor('_'));
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,8 +13,9 @@
namespace League\CommonMark\Extension\Mention\Generator;
use League\CommonMark\Exception\LogicException;
use League\CommonMark\Extension\Mention\Mention;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Inline\AbstractInline;
final class CallbackGenerator implements MentionGeneratorInterface
{
@@ -28,14 +31,17 @@ final class CallbackGenerator implements MentionGeneratorInterface
$this->callback = $callback;
}
/**
* @throws LogicException
*/
public function generateMention(Mention $mention): ?AbstractInline
{
$result = \call_user_func_array($this->callback, [$mention]);
$result = \call_user_func($this->callback, $mention);
if ($result === null) {
return null;
}
if ($result instanceof AbstractInline && !($result instanceof Mention)) {
if ($result instanceof AbstractInline && ! ($result instanceof Mention)) {
return $result;
}
@@ -43,6 +49,6 @@ final class CallbackGenerator implements MentionGeneratorInterface
return $mention;
}
throw new \RuntimeException('CallbackGenerator callable must set the URL on the passed mention and return the mention, return a new AbstractInline based object or null if the mention is not a match');
throw new LogicException('CallbackGenerator callable must set the URL on the passed mention and return the mention, return a new AbstractInline based object or null if the mention is not a match');
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -12,14 +14,9 @@
namespace League\CommonMark\Extension\Mention\Generator;
use League\CommonMark\Extension\Mention\Mention;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Inline\AbstractInline;
interface MentionGeneratorInterface
{
/**
* @param Mention $mention
*
* @return AbstractInline|null
*/
public function generateMention(Mention $mention): ?AbstractInline;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -12,12 +14,11 @@
namespace League\CommonMark\Extension\Mention\Generator;
use League\CommonMark\Extension\Mention\Mention;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Inline\AbstractInline;
final class StringTemplateLinkGenerator implements MentionGeneratorInterface
{
/** @var string */
private $urlTemplate;
private string $urlTemplate;
public function __construct(string $urlTemplate)
{
@@ -26,6 +27,8 @@ final class StringTemplateLinkGenerator implements MentionGeneratorInterface
public function generateMention(Mention $mention): ?AbstractInline
{
return $mention->setUrl(\sprintf($this->urlTemplate, $mention->getIdentifier()));
$mention->setUrl(\sprintf($this->urlTemplate, $mention->getIdentifier()));
return $mention;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -14,69 +16,56 @@
namespace League\CommonMark\Extension\Mention;
use League\CommonMark\Inline\Element\Link;
use League\CommonMark\Inline\Element\Text;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Node\Inline\Text;
class Mention extends Link
{
/** @var string */
private $symbol;
private string $name;
/** @var string */
private $identifier;
private string $prefix;
/**
* @param string $symbol
* @param string $identifier
* @param string $label
*/
public function __construct(string $symbol, string $identifier, string $label = null)
private string $identifier;
public function __construct(string $name, string $prefix, string $identifier, ?string $label = null)
{
$this->symbol = $symbol;
$this->name = $name;
$this->prefix = $prefix;
$this->identifier = $identifier;
parent::__construct('', $label ?? \sprintf('%s%s', $symbol, $identifier));
parent::__construct('', $label ?? \sprintf('%s%s', $prefix, $identifier));
}
/**
* @return string|null
*/
public function getLabel(): ?string
{
if (($labelNode = $this->findLabelNode()) === null) {
return null;
}
return $labelNode->getContent();
return $labelNode->getLiteral();
}
/**
* @return string
*/
public function getIdentifier(): string
{
return $this->identifier;
}
/**
* @return string
*/
public function getSymbol(): string
public function getName(): ?string
{
return $this->symbol;
return $this->name;
}
public function getPrefix(): string
{
return $this->prefix;
}
/**
* @return bool
*/
public function hasUrl(): bool
{
return !empty($this->url);
return $this->url !== '';
}
/**
* @param string $label
*
* @return $this
*/
public function setLabel(string $label): self
@@ -86,7 +75,7 @@ class Mention extends Link
$this->prependChild($labelNode);
}
$labelNode->setContent($label);
$labelNode->setLiteral($label);
return $this;
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,55 +13,49 @@
namespace League\CommonMark\Extension\Mention;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Exception\InvalidOptionException;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\CommonMark\Extension\Mention\Generator\MentionGeneratorInterface;
use League\Config\ConfigurationBuilderInterface;
use League\Config\Exception\InvalidConfigurationException;
use Nette\Schema\Expect;
final class MentionExtension implements ExtensionInterface
final class MentionExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$mentions = $environment->getConfig('mentions', []);
$isAValidPartialRegex = static function (string $regex): bool {
$regex = '/' . $regex . '/i';
return @\preg_match($regex, '') !== false;
};
$builder->addSchema('mentions', Expect::arrayOf(
Expect::structure([
'prefix' => Expect::string()->required(),
'pattern' => Expect::string()->assert($isAValidPartialRegex, 'Pattern must not include starting/ending delimiters (like "/")')->required(),
'generator' => Expect::anyOf(
Expect::type(MentionGeneratorInterface::class),
Expect::string(),
Expect::type('callable')
)->required(),
])
));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$mentions = $environment->getConfiguration()->get('mentions');
foreach ($mentions as $name => $mention) {
if (\array_key_exists('symbol', $mention)) {
@\trigger_error('The "mentions/*/symbol" configuration option is deprecated in league/commonmark 1.6; rename "symbol" to "prefix" for compatibility with 2.0', \E_USER_DEPRECATED);
$mention['prefix'] = $mention['symbol'];
}
if (\array_key_exists('pattern', $mention)) {
// v2.0 does not allow full regex patterns passed into the configuration
if (!self::isAValidPartialRegex($mention['pattern'])) {
throw new InvalidOptionException(\sprintf('Option "mentions/%s/pattern" must not include starting/ending delimiters (like "/")', $name));
}
$mention['pattern'] = '/' . $mention['pattern'] . '/i';
} elseif (\array_key_exists('regex', $mention)) {
@\trigger_error('The "mentions/*/regex" configuration option is deprecated in league/commonmark 1.6; rename "regex" to "pattern" for compatibility with 2.0', \E_USER_DEPRECATED);
$mention['pattern'] = $mention['regex'];
}
foreach (['prefix', 'pattern', 'generator'] as $key) {
if (empty($mention[$key])) {
throw new \RuntimeException("Missing \"$key\" from MentionParser configuration");
}
}
if ($mention['generator'] instanceof MentionGeneratorInterface) {
$environment->addInlineParser(new MentionParser($mention['prefix'], $mention['pattern'], $mention['generator']));
} elseif (is_string($mention['generator'])) {
$environment->addInlineParser(MentionParser::createWithStringTemplate($mention['prefix'], $mention['pattern'], $mention['generator']));
} elseif (is_callable($mention['generator'])) {
$environment->addInlineParser(MentionParser::createWithCallback($mention['prefix'], $mention['pattern'], $mention['generator']));
$environment->addInlineParser(new MentionParser($name, $mention['prefix'], $mention['pattern'], $mention['generator']));
} elseif (\is_string($mention['generator'])) {
$environment->addInlineParser(MentionParser::createWithStringTemplate($name, $mention['prefix'], $mention['pattern'], $mention['generator']));
} elseif (\is_callable($mention['generator'])) {
$environment->addInlineParser(MentionParser::createWithCallback($name, $mention['prefix'], $mention['pattern'], $mention['generator']));
} else {
throw new \RuntimeException(sprintf('The "generator" provided for the MentionParser configuration must be a string template, callable, or an object that implements %s.', MentionGeneratorInterface::class));
throw new InvalidConfigurationException(\sprintf('The "generator" provided for the "%s" MentionParser configuration must be a string template, callable, or an object that implements %s.', $name, MentionGeneratorInterface::class));
}
}
}
private static function isAValidPartialRegex(string $regex): bool
{
$regex = '/' . $regex . '/i';
return @\preg_match($regex, '') !== false;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -14,78 +16,72 @@ namespace League\CommonMark\Extension\Mention;
use League\CommonMark\Extension\Mention\Generator\CallbackGenerator;
use League\CommonMark\Extension\Mention\Generator\MentionGeneratorInterface;
use League\CommonMark\Extension\Mention\Generator\StringTemplateLinkGenerator;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\InlineParserContext;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
final class MentionParser implements InlineParserInterface
{
/** @var string */
private $symbol;
/** @psalm-readonly */
private string $name;
/** @var string */
private $mentionRegex;
/** @psalm-readonly */
private string $prefix;
/** @var MentionGeneratorInterface */
private $mentionGenerator;
/** @psalm-readonly */
private string $identifierPattern;
public function __construct(string $symbol, string $mentionRegex, MentionGeneratorInterface $mentionGenerator)
/** @psalm-readonly */
private MentionGeneratorInterface $mentionGenerator;
public function __construct(string $name, string $prefix, string $identifierPattern, MentionGeneratorInterface $mentionGenerator)
{
$this->symbol = $symbol;
$this->mentionRegex = $mentionRegex;
$this->mentionGenerator = $mentionGenerator;
$this->name = $name;
$this->prefix = $prefix;
$this->identifierPattern = $identifierPattern;
$this->mentionGenerator = $mentionGenerator;
}
public function getCharacters(): array
public function getMatchDefinition(): InlineParserMatch
{
return [$this->symbol];
return InlineParserMatch::join(
InlineParserMatch::string($this->prefix),
InlineParserMatch::regex($this->identifierPattern)
);
}
public function parse(InlineParserContext $inlineContext): bool
{
$cursor = $inlineContext->getCursor();
// The symbol must not have any other characters immediately prior
// The prefix must not have any other characters immediately prior
$previousChar = $cursor->peek(-1);
if ($previousChar !== null && \preg_match('/\w/', $previousChar)) {
// peek() doesn't modify the cursor, so no need to restore state first
return false;
}
// Save the cursor state in case we need to rewind and bail
$previousState = $cursor->saveState();
[$prefix, $identifier] = $inlineContext->getSubMatches();
// Advance past the symbol to keep parsing simpler
$cursor->advance();
// Parse the mention match value
$identifier = $cursor->match($this->mentionRegex);
if ($identifier === null) {
// Regex failed to match; this isn't a valid mention
$cursor->restoreState($previousState);
return false;
}
$mention = $this->mentionGenerator->generateMention(new Mention($this->symbol, $identifier));
$mention = $this->mentionGenerator->generateMention(new Mention($this->name, $prefix, $identifier));
if ($mention === null) {
$cursor->restoreState($previousState);
return false;
}
$cursor->advanceBy($inlineContext->getFullMatchLength());
$inlineContext->getContainer()->appendChild($mention);
return true;
}
public static function createWithStringTemplate(string $symbol, string $mentionRegex, string $urlTemplate): MentionParser
public static function createWithStringTemplate(string $name, string $prefix, string $mentionRegex, string $urlTemplate): MentionParser
{
return new self($symbol, $mentionRegex, new StringTemplateLinkGenerator($urlTemplate));
return new self($name, $prefix, $mentionRegex, new StringTemplateLinkGenerator($urlTemplate));
}
public static function createWithCallback(string $symbol, string $mentionRegex, callable $callback): MentionParser
public static function createWithCallback(string $name, string $prefix, string $mentionRegex, callable $callback): MentionParser
{
return new self($symbol, $mentionRegex, new CallbackGenerator($callback));
return new self($name, $prefix, $mentionRegex, new CallbackGenerator($callback));
}
}

View File

@@ -1,70 +0,0 @@
<?php
/*
* 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\Inline\Element\Text;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\InlineParserContext;
final class PunctuationParser implements InlineParserInterface
{
/**
* @return string[]
*/
public function getCharacters(): array
{
return ['-', '.'];
}
public function parse(InlineParserContext $inlineContext): bool
{
$cursor = $inlineContext->getCursor();
$ch = $cursor->getCharacter();
// Ellipses
if ($ch === '.' && $matched = $cursor->match('/^\\.( ?\\.)\\1/')) {
$inlineContext->getContainer()->appendChild(new Text('…'));
return true;
}
// Em/En-dashes
elseif ($ch === '-' && $matched = $cursor->match('/^(?<!-)(-{2,})/')) {
$count = strlen($matched);
$en_dash = '';
$en_count = 0;
$em_dash = '—';
$em_count = 0;
if ($count % 3 === 0) { // If divisible by 3, use all em dashes
$em_count = $count / 3;
} elseif ($count % 2 === 0) { // If divisible by 2, use all en dashes
$en_count = $count / 2;
} elseif ($count % 3 === 2) { // If 2 extra dashes, use en dash for last 2; em dashes for rest
$em_count = ($count - 2) / 3;
$en_count = 1;
} else { // Use en dashes for last 4 hyphens; em dashes for rest
$em_count = ($count - 4) / 3;
$en_count = 2;
}
$inlineContext->getContainer()->appendChild(new Text(
str_repeat($em_dash, (int) $em_count) . str_repeat($en_dash, (int) $en_count)
));
return true;
}
return false;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -14,15 +16,15 @@
namespace League\CommonMark\Extension\SmartPunct;
use League\CommonMark\Inline\Element\AbstractStringContainer;
use League\CommonMark\Node\Inline\AbstractStringContainer;
final class Quote extends AbstractStringContainer
{
public const DOUBLE_QUOTE = '"';
public const DOUBLE_QUOTE = '"';
public const DOUBLE_QUOTE_OPENER = '“';
public const DOUBLE_QUOTE_CLOSER = '”';
public const SINGLE_QUOTE = "'";
public const SINGLE_QUOTE = "'";
public const SINGLE_QUOTE_OPENER = '';
public const SINGLE_QUOTE_CLOSER = '';
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -15,21 +17,26 @@
namespace League\CommonMark\Extension\SmartPunct;
use League\CommonMark\Delimiter\Delimiter;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\InlineParserContext;
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];
public const SINGLE_QUOTES = [Quote::SINGLE_QUOTE, Quote::SINGLE_QUOTE_OPENER, Quote::SINGLE_QUOTE_CLOSER];
/**
* @return string[]
* @deprecated This constant is no longer used and will be removed in a future major release
*/
public function getCharacters(): array
public const SINGLE_QUOTES = [Quote::SINGLE_QUOTE, Quote::SINGLE_QUOTE_OPENER, Quote::SINGLE_QUOTE_CLOSER];
public function getMatchDefinition(): InlineParserMatch
{
return array_merge(self::DOUBLE_QUOTES, self::SINGLE_QUOTES);
return InlineParserMatch::oneOf(Quote::SINGLE_QUOTE, Quote::DOUBLE_QUOTE);
}
/**
@@ -37,8 +44,8 @@ final class QuoteParser implements InlineParserInterface
*/
public function parse(InlineParserContext $inlineContext): bool
{
$char = $inlineContext->getFullMatch();
$cursor = $inlineContext->getCursor();
$normalizedCharacter = $this->getNormalizedQuoteCharacter($cursor->getCharacter());
$charBefore = $cursor->peek(-1);
if ($charBefore === null) {
@@ -47,57 +54,43 @@ final class QuoteParser implements InlineParserInterface
$cursor->advance();
$charAfter = $cursor->getCharacter();
$charAfter = $cursor->getCurrentCharacter();
if ($charAfter === null) {
$charAfter = "\n";
}
[$leftFlanking, $rightFlanking] = $this->determineFlanking($charBefore, $charAfter);
$canOpen = $leftFlanking && !$rightFlanking;
$canClose = $rightFlanking;
$canOpen = $leftFlanking && ! $rightFlanking;
$canClose = $rightFlanking;
$node = new Quote($normalizedCharacter, ['delim' => true]);
$node = new Quote($char, ['delim' => true]);
$inlineContext->getContainer()->appendChild($node);
// Add entry to stack to this opener
$inlineContext->getDelimiterStack()->push(new Delimiter($normalizedCharacter, 1, $node, $canOpen, $canClose));
$inlineContext->getDelimiterStack()->push(new Delimiter($char, 1, $node, $canOpen, $canClose));
return true;
}
private function getNormalizedQuoteCharacter(string $character): string
{
if (in_array($character, self::DOUBLE_QUOTES)) {
return Quote::DOUBLE_QUOTE;
} elseif (in_array($character, self::SINGLE_QUOTES)) {
return Quote::SINGLE_QUOTE;
}
return $character;
}
/**
* @param string $charBefore
* @param string $charAfter
*
* @return bool[]
*/
private function determineFlanking(string $charBefore, string $charAfter)
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);
$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);
$leftFlanking = ! $afterIsWhitespace &&
! ($afterIsPunctuation &&
! $beforeIsWhitespace &&
! $beforeIsPunctuation);
$rightFlanking = !$beforeIsWhitespace &&
!($beforeIsPunctuation &&
!$afterIsWhitespace &&
!$afterIsPunctuation);
$rightFlanking = ! $beforeIsWhitespace &&
! ($beforeIsPunctuation &&
! $afterIsWhitespace &&
! $afterIsPunctuation);
return [$leftFlanking, $rightFlanking];
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -16,24 +18,24 @@ namespace League\CommonMark\Extension\SmartPunct;
use League\CommonMark\Delimiter\DelimiterInterface;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
use League\CommonMark\Inline\Element\AbstractStringContainer;
use League\CommonMark\Node\Inline\AbstractStringContainer;
final class QuoteProcessor implements DelimiterProcessorInterface
{
/** @var string */
private $normalizedCharacter;
/** @psalm-readonly */
private string $normalizedCharacter;
/** @var string */
private $openerCharacter;
/** @psalm-readonly */
private string $openerCharacter;
/** @var string */
private $closerCharacter;
/** @psalm-readonly */
private string $closerCharacter;
private function __construct(string $char, string $opener, string $closer)
{
$this->normalizedCharacter = $char;
$this->openerCharacter = $opener;
$this->closerCharacter = $closer;
$this->openerCharacter = $opener;
$this->closerCharacter = $closer;
}
public function getOpeningCharacter(): string
@@ -56,7 +58,7 @@ final class QuoteProcessor implements DelimiterProcessorInterface
return 1;
}
public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse)
public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse): void
{
$opener->insertAfter(new Quote($this->openerCharacter));
$closer->insertBefore(new Quote($this->closerCharacter));
@@ -64,11 +66,6 @@ final class QuoteProcessor implements DelimiterProcessorInterface
/**
* Create a double-quote processor
*
* @param string $opener
* @param string $closer
*
* @return QuoteProcessor
*/
public static function createDoubleQuoteProcessor(string $opener = Quote::DOUBLE_QUOTE_OPENER, string $closer = Quote::DOUBLE_QUOTE_CLOSER): self
{
@@ -77,11 +74,6 @@ final class QuoteProcessor implements DelimiterProcessorInterface
/**
* Create a single-quote processor
*
* @param string $opener
* @param string $closer
*
* @return QuoteProcessor
*/
public static function createSingleQuoteProcessor(string $opener = Quote::SINGLE_QUOTE_OPENER, string $closer = Quote::SINGLE_QUOTE_CLOSER): self
{

View File

@@ -1,47 +0,0 @@
<?php
/*
* 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\ElementRendererInterface;
use League\CommonMark\HtmlElement;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
final class QuoteRenderer implements InlineRendererInterface
{
/**
* @param Quote $inline
* @param ElementRendererInterface $htmlRenderer
*
* @return HtmlElement|string|null
*/
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
{
if (!$inline instanceof Quote) {
throw new \InvalidArgumentException(sprintf('Expected an instance of "%s", got "%s" instead', Quote::class, get_class($inline)));
}
// Handles unpaired quotes which remain after processing delimiters
if ($inline->getContent() === Quote::SINGLE_QUOTE) {
// Render as an apostrophe
return Quote::SINGLE_QUOTE_CLOSER;
} elseif ($inline->getContent() === Quote::DOUBLE_QUOTE) {
// Render as an opening quote
return Quote::DOUBLE_QUOTE_OPENER;
}
return $inline->getContent();
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -14,36 +16,49 @@
namespace League\CommonMark\Extension\SmartPunct;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\Block\Renderer as CoreBlockRenderer;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Inline\Element\Text;
use League\CommonMark\Inline\Renderer as CoreInlineRenderer;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\CommonMark\Node\Block\Document;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Renderer\Block as CoreBlockRenderer;
use League\CommonMark\Renderer\Inline as CoreInlineRenderer;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class SmartPunctExtension implements ExtensionInterface
final class SmartPunctExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$builder->addSchema('smartpunct', Expect::structure([
'double_quote_opener' => Expect::string(Quote::DOUBLE_QUOTE_OPENER),
'double_quote_closer' => Expect::string(Quote::DOUBLE_QUOTE_CLOSER),
'single_quote_opener' => Expect::string(Quote::SINGLE_QUOTE_OPENER),
'single_quote_closer' => Expect::string(Quote::SINGLE_QUOTE_CLOSER),
]));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment
->addInlineParser(new QuoteParser(), 10)
->addInlineParser(new PunctuationParser(), 0)
->addInlineParser(new DashParser(), 0)
->addInlineParser(new EllipsesParser(), 0)
->addDelimiterProcessor(QuoteProcessor::createDoubleQuoteProcessor(
$environment->getConfig('smartpunct/double_quote_opener', Quote::DOUBLE_QUOTE_OPENER),
$environment->getConfig('smartpunct/double_quote_closer', Quote::DOUBLE_QUOTE_CLOSER)
$environment->getConfiguration()->get('smartpunct/double_quote_opener'),
$environment->getConfiguration()->get('smartpunct/double_quote_closer')
))
->addDelimiterProcessor(QuoteProcessor::createSingleQuoteProcessor(
$environment->getConfig('smartpunct/single_quote_opener', Quote::SINGLE_QUOTE_OPENER),
$environment->getConfig('smartpunct/single_quote_closer', Quote::SINGLE_QUOTE_CLOSER)
$environment->getConfiguration()->get('smartpunct/single_quote_opener'),
$environment->getConfiguration()->get('smartpunct/single_quote_closer')
))
->addBlockRenderer(Document::class, new CoreBlockRenderer\DocumentRenderer(), 0)
->addBlockRenderer(Paragraph::class, new CoreBlockRenderer\ParagraphRenderer(), 0)
->addEventListener(DocumentParsedEvent::class, new ReplaceUnpairedQuotesListener())
->addInlineRenderer(Quote::class, new QuoteRenderer(), 100)
->addInlineRenderer(Text::class, new CoreInlineRenderer\TextRenderer(), 0)
;
->addRenderer(Document::class, new CoreBlockRenderer\DocumentRenderer(), 0)
->addRenderer(Paragraph::class, new CoreBlockRenderer\ParagraphRenderer(), 0)
->addRenderer(Text::class, new CoreInlineRenderer\TextRenderer(), 0);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,12 +13,27 @@
namespace League\CommonMark\Extension\Strikethrough;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Inline\AbstractInline;
use League\CommonMark\Node\Inline\DelimitedInterface;
final class Strikethrough extends AbstractInline
final class Strikethrough extends AbstractInline implements DelimitedInterface
{
public function isContainer(): bool
private string $delimiter;
public function __construct(string $delimiter = '~~')
{
return true;
parent::__construct();
$this->delimiter = $delimiter;
}
public function getOpeningDelimiter(): string
{
return $this->delimiter;
}
public function getClosingDelimiter(): string
{
return $this->delimiter;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -13,7 +15,7 @@ namespace League\CommonMark\Extension\Strikethrough;
use League\CommonMark\Delimiter\DelimiterInterface;
use League\CommonMark\Delimiter\Processor\DelimiterProcessorInterface;
use League\CommonMark\Inline\Element\AbstractStringContainer;
use League\CommonMark\Node\Inline\AbstractStringContainer;
final class StrikethroughDelimiterProcessor implements DelimiterProcessorInterface
{
@@ -29,19 +31,25 @@ final class StrikethroughDelimiterProcessor implements DelimiterProcessorInterfa
public function getMinLength(): int
{
return 2;
return 1;
}
public function getDelimiterUse(DelimiterInterface $opener, DelimiterInterface $closer): int
{
$min = \min($opener->getLength(), $closer->getLength());
if ($opener->getLength() > 2 && $closer->getLength() > 2) {
return 0;
}
return $min >= 2 ? $min : 0;
if ($opener->getLength() !== $closer->getLength()) {
return 0;
}
return \min($opener->getLength(), $closer->getLength());
}
public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse)
public function process(AbstractStringContainer $opener, AbstractStringContainer $closer, int $delimiterUse): void
{
$strikethrough = new Strikethrough();
$strikethrough = new Strikethrough(\str_repeat('~', $delimiterUse));
$tmp = $opener->next();
while ($tmp !== null && $tmp !== $closer) {

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,14 +13,14 @@
namespace League\CommonMark\Extension\Strikethrough;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
final class StrikethroughExtension implements ExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor());
$environment->addInlineRenderer(Strikethrough::class, new StrikethroughRenderer());
$environment->addRenderer(Strikethrough::class, new StrikethroughRenderer());
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,19 +13,38 @@
namespace League\CommonMark\Extension\Strikethrough;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\HtmlElement;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
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 StrikethroughRenderer implements InlineRendererInterface
final class StrikethroughRenderer implements NodeRendererInterface, XmlNodeRendererInterface
{
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
/**
* @param Strikethrough $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
if (!($inline instanceof Strikethrough)) {
throw new \InvalidArgumentException('Incompatible inline type: ' . get_class($inline));
}
Strikethrough::assertInstanceOf($node);
return new HtmlElement('del', $inline->getData('attributes', []), $htmlRenderer->renderInlines($inline->children()));
return new HtmlElement('del', $node->data->get('attributes'), $childRenderer->renderNodes($node->children()));
}
public function getXmlTagName(Node $node): string
{
return 'strikethrough';
}
/**
* {@inheritDoc}
*/
public function getXmlAttributes(Node $node): array
{
return [];
}
}

View File

@@ -15,55 +15,8 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Element\AbstractStringContainerBlock;
use League\CommonMark\Block\Element\InlineContainerInterface;
use League\CommonMark\ContextInterface;
use League\CommonMark\Cursor;
use League\CommonMark\Node\Block\AbstractBlock;
final class Table extends AbstractStringContainerBlock implements InlineContainerInterface
final class Table extends AbstractBlock
{
/** @var TableSection */
private $head;
/** @var TableSection */
private $body;
/** @var \Closure */
private $parser;
public function __construct(\Closure $parser)
{
parent::__construct();
$this->appendChild($this->head = new TableSection(TableSection::TYPE_HEAD));
$this->appendChild($this->body = new TableSection(TableSection::TYPE_BODY));
$this->parser = $parser;
}
public function canContain(AbstractBlock $block): bool
{
return $block instanceof TableSection;
}
public function isCode(): bool
{
return false;
}
public function getHead(): TableSection
{
return $this->head;
}
public function getBody(): TableSection
{
return $this->body;
}
public function matchesNextLine(Cursor $cursor): bool
{
return call_user_func($this->parser, $cursor, $this);
}
public function handleRemainingContents(ContextInterface $context, Cursor $cursor): void
{
}
}

View File

@@ -15,52 +15,85 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Element\AbstractStringContainerBlock;
use League\CommonMark\Block\Element\InlineContainerInterface;
use League\CommonMark\ContextInterface;
use League\CommonMark\Cursor;
use League\CommonMark\Node\Block\AbstractBlock;
final class TableCell extends AbstractStringContainerBlock implements InlineContainerInterface
final class TableCell extends AbstractBlock
{
const TYPE_HEAD = 'th';
const TYPE_BODY = 'td';
public const TYPE_HEADER = 'header';
public const TYPE_DATA = 'data';
const ALIGN_LEFT = 'left';
const ALIGN_RIGHT = 'right';
const ALIGN_CENTER = 'center';
public const ALIGN_LEFT = 'left';
public const ALIGN_RIGHT = 'right';
public const ALIGN_CENTER = 'center';
/** @var string */
public $type = self::TYPE_BODY;
/**
* @psalm-var self::TYPE_*
* @phpstan-var self::TYPE_*
*
* @psalm-readonly-allow-private-mutation
*/
private string $type = self::TYPE_DATA;
/** @var string|null */
public $align;
/**
* @psalm-var self::ALIGN_*|null
* @phpstan-var self::ALIGN_*|null
*
* @psalm-readonly-allow-private-mutation
*/
private ?string $align = null;
public function __construct(string $string = '', string $type = self::TYPE_BODY, string $align = null)
/**
* @psalm-param self::TYPE_* $type
* @psalm-param self::ALIGN_*|null $align
*
* @phpstan-param self::TYPE_* $type
* @phpstan-param self::ALIGN_*|null $align
*/
public function __construct(string $type = self::TYPE_DATA, ?string $align = null)
{
parent::__construct();
$this->finalStringContents = $string;
$this->addLine($string);
$this->type = $type;
$this->type = $type;
$this->align = $align;
}
public function canContain(AbstractBlock $block): bool
/**
* @psalm-return self::TYPE_*
*
* @phpstan-return self::TYPE_*
*/
public function getType(): string
{
return false;
return $this->type;
}
public function isCode(): bool
/**
* @psalm-param self::TYPE_* $type
*
* @phpstan-param self::TYPE_* $type
*/
public function setType(string $type): void
{
return false;
$this->type = $type;
}
public function matchesNextLine(Cursor $cursor): bool
/**
* @psalm-return self::ALIGN_*|null
*
* @phpstan-return self::ALIGN_*|null
*/
public function getAlign(): ?string
{
return false;
return $this->align;
}
public function handleRemainingContents(ContextInterface $context, Cursor $cursor): void
/**
* @psalm-param self::ALIGN_*|null $align
*
* @phpstan-param self::ALIGN_*|null $align
*/
public function setAlign(?string $align): void
{
$this->align = $align;
}
}

View File

@@ -15,25 +15,75 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\HtmlElement;
use League\CommonMark\Extension\Attributes\Util\AttributesHelper;
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 TableCellRenderer implements BlockRendererInterface
final class TableCellRenderer implements NodeRendererInterface, XmlNodeRendererInterface
{
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
private const DEFAULT_ATTRIBUTES = [
TableCell::ALIGN_LEFT => ['align' => 'left'],
TableCell::ALIGN_CENTER => ['align' => 'center'],
TableCell::ALIGN_RIGHT => ['align' => 'right'],
];
/** @var array<TableCell::ALIGN_*, array<string, string|string[]|bool>> */
private array $alignmentAttributes;
/**
* @param array<TableCell::ALIGN_*, array<string, string|string[]|bool>> $alignmentAttributes
*/
public function __construct(array $alignmentAttributes = self::DEFAULT_ATTRIBUTES)
{
if (!$block instanceof TableCell) {
throw new \InvalidArgumentException('Incompatible block type: ' . get_class($block));
$this->alignmentAttributes = $alignmentAttributes;
}
/**
* @param TableCell $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
TableCell::assertInstanceOf($node);
$attrs = $node->data->get('attributes');
if (($alignment = $node->getAlign()) !== null) {
$attrs = AttributesHelper::mergeAttributes($attrs, $this->alignmentAttributes[$alignment]);
}
$attrs = $block->getData('attributes', []);
$tag = $node->getType() === TableCell::TYPE_HEADER ? 'th' : 'td';
if ($block->align !== null) {
$attrs['align'] = $block->align;
return new HtmlElement($tag, $attrs, $childRenderer->renderNodes($node->children()));
}
public function getXmlTagName(Node $node): string
{
return 'table_cell';
}
/**
* @param TableCell $node
*
* @return array<string, scalar>
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function getXmlAttributes(Node $node): array
{
TableCell::assertInstanceOf($node);
$ret = ['type' => $node->getType()];
if (($align = $node->getAlign()) !== null) {
$ret['align'] = $align;
}
return new HtmlElement($block->type, $attrs, $htmlRenderer->renderInlines($block->children()));
return $ret;
}
}

View File

@@ -15,20 +15,48 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Extension\ExtensionInterface;
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 ExtensionInterface
final class TableExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment): void
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$environment
->addBlockParser(new TableParser())
$attributeArraySchema = Expect::arrayOf(
Expect::type('string|string[]|bool'), // attribute value(s)
'string' // attribute name
)->mergeDefaults(false);
->addBlockRenderer(Table::class, new TableRenderer())
->addBlockRenderer(TableSection::class, new TableSectionRenderer())
->addBlockRenderer(TableRow::class, new TableRowRenderer())
->addBlockRenderer(TableCell::class, new TableCellRenderer())
;
$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']),
]),
]));
}
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())
->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')));
}
}

View File

@@ -15,144 +15,158 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\Block\Parser\BlockParserInterface;
use League\CommonMark\Context;
use League\CommonMark\ContextInterface;
use League\CommonMark\Cursor;
use League\CommonMark\EnvironmentAwareInterface;
use League\CommonMark\EnvironmentInterface;
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 implements BlockParserInterface, EnvironmentAwareInterface
final class TableParser extends AbstractBlockContinueParser implements BlockContinueParserWithInlinesInterface
{
/** @psalm-readonly */
private Table $block;
/**
* @var EnvironmentInterface
* @var ArrayCollection<string>
*
* @psalm-readonly-allow-private-mutation
*/
private $environment;
private ArrayCollection $bodyLines;
public function parse(ContextInterface $context, Cursor $cursor): bool
/**
* @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;
/**
* @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)
{
$container = $context->getContainer();
if (!$container instanceof Paragraph) {
return false;
}
$lines = $container->getStrings();
if (count($lines) === 0) {
return false;
}
$lastLine = \array_pop($lines);
if (\strpos($lastLine, '|') === false) {
return false;
}
$oldState = $cursor->saveState();
$cursor->advanceToNextNonSpaceOrTab();
$columns = $this->parseColumns($cursor);
if (empty($columns)) {
$cursor->restoreState($oldState);
return false;
}
$head = $this->parseRow(trim((string) $lastLine), $columns, TableCell::TYPE_HEAD);
if (null === $head) {
$cursor->restoreState($oldState);
return false;
}
$table = new Table(function (Cursor $cursor, Table $table) use ($columns): bool {
// The next line cannot be a new block start
// This is a bit inefficient, but it's the only feasible way to check
// given the current v1 API.
if (self::isANewBlock($this->environment, $cursor->getLine())) {
return false;
}
$row = $this->parseRow(\trim($cursor->getLine()), $columns);
if (null === $row) {
return false;
}
$table->getBody()->appendChild($row);
return true;
});
$table->getHead()->appendChild($head);
if (count($lines) >= 1) {
$paragraph = new Paragraph();
foreach ($lines as $line) {
$paragraph->addLine($line);
}
$context->replaceContainerBlock($paragraph);
$context->addBlock($table);
} else {
$context->replaceContainerBlock($table);
}
$this->block = new Table();
$this->bodyLines = new ArrayCollection();
$this->columns = $columns;
$this->headerCells = $headerCells;
}
public function canHaveLazyContinuationLines(): bool
{
return true;
}
/**
* @param string $line
* @param array<int, string> $columns
* @param string $type
*
* @return TableRow|null
*/
private function parseRow(string $line, array $columns, string $type = TableCell::TYPE_BODY): ?TableRow
public function getBlock(): Table
{
$cells = $this->split(new Cursor(\trim($line)));
return $this->block;
}
if (empty($cells)) {
return null;
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
{
if (\strpos($cursor->getLine(), '|') === false) {
return BlockContinue::none();
}
// The header row must match the delimiter row in the number of cells
if ($type === TableCell::TYPE_HEAD && \count($cells) !== \count($columns)) {
return null;
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);
}
$i = 0;
$row = new TableRow();
foreach ($cells as $i => $cell) {
if (!array_key_exists($i, $columns)) {
return $row;
$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++) {
$cell = $cells[$i] ?? '';
$tableCell = $this->parseCell($cell, $i, $inlineParser);
$row->appendChild($tableCell);
}
$row->appendChild(new TableCell(trim($cell), $type, $columns[$i]));
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();
if ($column < \count($this->columns)) {
$tableCell->setAlign($this->columns[$column]);
}
for ($j = count($columns) - 1; $j > $i; --$j) {
$row->appendChild(new TableCell('', $type, null));
}
$inlineParser->parse(\trim($cell), $tableCell);
return $row;
return $tableCell;
}
/**
* @param Cursor $cursor
* @internal
*
* @return array<int, string>
*/
private function split(Cursor $cursor): array
public static function split(string $line): array
{
if ($cursor->getCharacter() === '|') {
$cursor = new Cursor(\trim($line));
if ($cursor->getCurrentCharacter() === '|') {
$cursor->advanceBy(1);
}
$cells = [];
$sb = '';
$sb = '';
while (!$cursor->isAtEnd()) {
switch ($c = $cursor->getCharacter()) {
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
@@ -164,14 +178,16 @@ final class TableParser implements BlockParserInterface, EnvironmentAwareInterfa
// Preserve backslash before other characters or at end of line.
$sb .= '\\';
}
break;
case '|':
$cells[] = $sb;
$sb = '';
$sb = '';
break;
default:
$sb .= $c;
}
$cursor->advanceBy(1);
}
@@ -181,104 +197,4 @@ final class TableParser implements BlockParserInterface, EnvironmentAwareInterfa
return $cells;
}
/**
* @param Cursor $cursor
*
* @return array<int, string>
*/
private function parseColumns(Cursor $cursor): array
{
$columns = [];
$pipes = 0;
$valid = false;
while (!$cursor->isAtEnd()) {
switch ($c = $cursor->getCharacter()) {
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 && !empty($columns)) {
// 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->getCharacter() === ':') {
$right = true;
$cursor->advanceBy(1);
}
$columns[] = $this->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;
}
private static function getAlignment(bool $left, bool $right): ?string
{
if ($left && $right) {
return TableCell::ALIGN_CENTER;
} elseif ($left) {
return TableCell::ALIGN_LEFT;
} elseif ($right) {
return TableCell::ALIGN_RIGHT;
}
return null;
}
public function setEnvironment(EnvironmentInterface $environment)
{
$this->environment = $environment;
}
private static function isANewBlock(EnvironmentInterface $environment, string $line): bool
{
$context = new Context(new Document(), $environment);
$context->setNextLine($line);
$cursor = new Cursor($line);
/** @var BlockParserInterface $parser */
foreach ($environment->getBlockParsers() as $parser) {
if ($parser->parse($context, $cursor)) {
return true;
}
}
return false;
}
}

View File

@@ -15,25 +15,44 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\HtmlElement;
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 TableRenderer implements BlockRendererInterface
final class TableRenderer implements NodeRendererInterface, XmlNodeRendererInterface
{
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
/**
* @param Table $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
if (!$block instanceof Table) {
throw new \InvalidArgumentException('Incompatible block type: ' . get_class($block));
}
Table::assertInstanceOf($node);
$attrs = $block->getData('attributes', []);
$attrs = $node->data->get('attributes');
$separator = $htmlRenderer->getOption('inner_separator', "\n");
$separator = $childRenderer->getInnerSeparator();
$children = $htmlRenderer->renderBlocks($block->children());
$children = $childRenderer->renderNodes($node->children());
return new HtmlElement('table', $attrs, $separator . \trim($children) . $separator);
}
public function getXmlTagName(Node $node): string
{
return 'table';
}
/**
* {@inheritDoc}
*/
public function getXmlAttributes(Node $node): array
{
return [];
}
}

View File

@@ -15,34 +15,8 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Cursor;
use League\CommonMark\Node\Node;
use League\CommonMark\Node\Block\AbstractBlock;
final class TableRow extends AbstractBlock
{
public function canContain(AbstractBlock $block): bool
{
return $block instanceof TableCell;
}
public function isCode(): bool
{
return false;
}
public function matchesNextLine(Cursor $cursor): bool
{
return false;
}
/**
* @return AbstractBlock[]
*/
public function children(): iterable
{
return array_filter((array) parent::children(), static function (Node $child): bool {
return $child instanceof AbstractBlock;
});
}
}

View File

@@ -15,23 +15,42 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\HtmlElement;
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 TableRowRenderer implements BlockRendererInterface
final class TableRowRenderer implements NodeRendererInterface, XmlNodeRendererInterface
{
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
/**
* @param TableRow $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
if (!$block instanceof TableRow) {
throw new \InvalidArgumentException('Incompatible block type: ' . get_class($block));
}
TableRow::assertInstanceOf($node);
$attrs = $block->getData('attributes', []);
$attrs = $node->data->get('attributes');
$separator = $htmlRenderer->getOption('inner_separator', "\n");
$separator = $childRenderer->getInnerSeparator();
return new HtmlElement('tr', $attrs, $separator . $htmlRenderer->renderBlocks($block->children()) . $separator);
return new HtmlElement('tr', $attrs, $separator . $childRenderer->renderNodes($node->children()) . $separator);
}
public function getXmlTagName(Node $node): string
{
return 'table_row';
}
/**
* {@inheritDoc}
*/
public function getXmlAttributes(Node $node): array
{
return [];
}
}

View File

@@ -15,52 +15,50 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Element\AbstractStringContainerBlock;
use League\CommonMark\Block\Element\InlineContainerInterface;
use League\CommonMark\ContextInterface;
use League\CommonMark\Cursor;
use League\CommonMark\Node\Block\AbstractBlock;
final class TableSection extends AbstractStringContainerBlock implements InlineContainerInterface
final class TableSection extends AbstractBlock
{
const TYPE_HEAD = 'thead';
const TYPE_BODY = 'tbody';
public const TYPE_HEAD = 'head';
public const TYPE_BODY = 'body';
/** @var string */
public $type = self::TYPE_BODY;
/**
* @psalm-var self::TYPE_*
* @phpstan-var self::TYPE_*
*
* @psalm-readonly
*/
private string $type;
/**
* @psalm-param self::TYPE_* $type
*
* @phpstan-param self::TYPE_* $type
*/
public function __construct(string $type = self::TYPE_BODY)
{
parent::__construct();
$this->type = $type;
}
/**
* @psalm-return self::TYPE_*
*
* @phpstan-return self::TYPE_*
*/
public function getType(): string
{
return $this->type;
}
public function isHead(): bool
{
return self::TYPE_HEAD === $this->type;
return $this->type === self::TYPE_HEAD;
}
public function isBody(): bool
{
return self::TYPE_BODY === $this->type;
}
public function canContain(AbstractBlock $block): bool
{
return $block instanceof TableRow;
}
public function isCode(): bool
{
return false;
}
public function matchesNextLine(Cursor $cursor): bool
{
return false;
}
public function handleRemainingContents(ContextInterface $context, Cursor $cursor): void
{
return $this->type === self::TYPE_BODY;
}
}

View File

@@ -15,27 +15,56 @@ declare(strict_types=1);
namespace League\CommonMark\Extension\Table;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\HtmlElement;
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 TableSectionRenderer implements BlockRendererInterface
final class TableSectionRenderer implements NodeRendererInterface, XmlNodeRendererInterface
{
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
/**
* @param TableSection $node
*
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
if (!$block instanceof TableSection) {
throw new \InvalidArgumentException('Incompatible block type: ' . get_class($block));
}
TableSection::assertInstanceOf($node);
if (!$block->hasChildren()) {
if (! $node->hasChildren()) {
return '';
}
$attrs = $block->getData('attributes', []);
$attrs = $node->data->get('attributes');
$separator = $htmlRenderer->getOption('inner_separator', "\n");
$separator = $childRenderer->getInnerSeparator();
return new HtmlElement($block->type, $attrs, $separator . $htmlRenderer->renderBlocks($block->children()) . $separator);
$tag = $node->getType() === TableSection::TYPE_HEAD ? 'thead' : 'tbody';
return new HtmlElement($tag, $attrs, $separator . $childRenderer->renderNodes($node->children()) . $separator);
}
public function getXmlTagName(Node $node): string
{
return 'table_section';
}
/**
* @param TableSection $node
*
* @return array<string, scalar>
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function getXmlAttributes(Node $node): array
{
TableSection::assertInstanceOf($node);
return [
'type' => $node->getType(),
];
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,11 +13,8 @@
namespace League\CommonMark\Extension\TableOfContents\Node;
use League\CommonMark\Block\Element\ListBlock;
use League\CommonMark\Extension\TableOfContents\TableOfContents as DeprecatedTableOfContents;
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
final class TableOfContents extends ListBlock
{
}
\class_exists(DeprecatedTableOfContents::class);

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,23 +13,8 @@
namespace League\CommonMark\Extension\TableOfContents\Node;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Cursor;
use League\CommonMark\Node\Block\AbstractBlock;
final class TableOfContentsPlaceholder extends AbstractBlock
{
public function canContain(AbstractBlock $block): bool
{
return false;
}
public function isCode(): bool
{
return false;
}
public function matchesNextLine(Cursor $cursor): bool
{
return false;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,18 +13,20 @@
namespace League\CommonMark\Extension\TableOfContents\Normalizer;
use League\CommonMark\Block\Element\ListBlock;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
final class AsIsNormalizerStrategy implements NormalizerStrategyInterface
{
/** @var ListBlock */
private $parentListBlock;
/** @var int */
private $parentLevel = 1;
/** @var ListItem|null */
private $lastListItem;
/** @psalm-readonly-allow-private-mutation */
private ListBlock $parentListBlock;
/** @psalm-readonly-allow-private-mutation */
private int $parentLevel = 1;
/** @psalm-readonly-allow-private-mutation */
private ?ListItem $lastListItem = null;
public function __construct(TableOfContents $toc)
{
@@ -43,16 +47,17 @@ final class AsIsNormalizerStrategy implements NormalizerStrategyInterface
$newListBlock->setEndLine($listItemToAdd->getEndLine());
$this->lastListItem->appendChild($newListBlock);
$this->parentListBlock = $newListBlock;
$this->lastListItem = null;
$this->lastListItem = null;
$this->parentLevel++;
}
while ($level < $this->parentLevel) {
// Search upwards for the previous parent list block
while (true) {
$this->parentListBlock = $this->parentListBlock->parent();
if ($this->parentListBlock instanceof ListBlock) {
$search = $this->parentListBlock;
while ($search = $search->parent()) {
if ($search instanceof ListBlock) {
$this->parentListBlock = $search;
break;
}
}
@@ -65,6 +70,3 @@ final class AsIsNormalizerStrategy implements NormalizerStrategyInterface
$this->lastListItem = $listItemToAdd;
}
}
// Trigger autoload without causing a deprecated error
\class_exists(TableOfContents::class);

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,13 +13,13 @@
namespace League\CommonMark\Extension\TableOfContents\Normalizer;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
final class FlatNormalizerStrategy implements NormalizerStrategyInterface
{
/** @var TableOfContents */
private $toc;
/** @psalm-readonly */
private TableOfContents $toc;
public function __construct(TableOfContents $toc)
{
@@ -29,6 +31,3 @@ final class FlatNormalizerStrategy implements NormalizerStrategyInterface
$this->toc->appendChild($listItemToAdd);
}
}
// Trigger autoload without causing a deprecated error
\class_exists(TableOfContents::class);

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,7 +13,7 @@
namespace League\CommonMark\Extension\TableOfContents\Normalizer;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
interface NormalizerStrategyInterface
{

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,17 +13,21 @@
namespace League\CommonMark\Extension\TableOfContents\Normalizer;
use League\CommonMark\Block\Element\ListBlock;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
final class RelativeNormalizerStrategy implements NormalizerStrategyInterface
{
/** @var TableOfContents */
private $toc;
/** @psalm-readonly */
private TableOfContents $toc;
/** @var array<int, ListItem> */
private $listItemStack = [];
/**
* @var array<int, ListItem>
*
* @psalm-readonly-allow-private-mutation
*/
private array $listItemStack = [];
public function __construct(TableOfContents $toc)
{
@@ -30,18 +36,15 @@ final class RelativeNormalizerStrategy implements NormalizerStrategyInterface
public function addItem(int $level, ListItem $listItemToAdd): void
{
\end($this->listItemStack);
$previousLevel = \key($this->listItemStack);
$previousLevel = \array_key_last($this->listItemStack);
// Pop the stack if we're too deep
while ($previousLevel !== null && $level < $previousLevel) {
array_pop($this->listItemStack);
\end($this->listItemStack);
$previousLevel = \key($this->listItemStack);
\array_pop($this->listItemStack);
$previousLevel = \array_key_last($this->listItemStack);
}
/** @var ListItem|false $lastListItem */
$lastListItem = \current($this->listItemStack);
$lastListItem = \end($this->listItemStack);
// Need to go one level deeper? Add that level
if ($lastListItem !== false && $level > $previousLevel) {
@@ -62,6 +65,3 @@ final class RelativeNormalizerStrategy implements NormalizerStrategyInterface
$this->listItemStack[$level] = $listItemToAdd;
}
}
// Trigger autoload without causing a deprecated error
\class_exists(TableOfContents::class);

View File

@@ -1,30 +0,0 @@
<?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.
*/
namespace League\CommonMark\Extension\TableOfContents;
use League\CommonMark\Block\Element\ListBlock;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents as NewTableOfContents;
if (!class_exists(NewTableOfContents::class)) {
@trigger_error(sprintf('TableOfContents has moved to a new namespace; use %s instead', NewTableOfContents::class), \E_USER_DEPRECATED);
}
\class_alias(NewTableOfContents::class, TableOfContents::class);
if (false) {
/**
* @deprecated This class has moved to the Node sub-namespace; use that instead
*/
final class TableOfContents extends ListBlock
{
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,59 +13,36 @@
namespace League\CommonMark\Extension\TableOfContents;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Block\Element\Heading;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Exception\InvalidOptionException;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalink;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
use League\CommonMark\Node\Block\Document;
use League\CommonMark\Node\NodeIterator;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
use League\Config\Exception\InvalidConfigurationException;
final class TableOfContentsBuilder implements ConfigurationAwareInterface
{
/**
* @deprecated Use TableOfContentsGenerator::STYLE_BULLET instead
*/
public const STYLE_BULLET = TableOfContentsGenerator::STYLE_BULLET;
/**
* @deprecated Use TableOfContentsGenerator::STYLE_ORDERED instead
*/
public const STYLE_ORDERED = TableOfContentsGenerator::STYLE_ORDERED;
/**
* @deprecated Use TableOfContentsGenerator::NORMALIZE_DISABLED instead
*/
public const NORMALIZE_DISABLED = TableOfContentsGenerator::NORMALIZE_DISABLED;
/**
* @deprecated Use TableOfContentsGenerator::NORMALIZE_RELATIVE instead
*/
public const NORMALIZE_RELATIVE = TableOfContentsGenerator::NORMALIZE_RELATIVE;
/**
* @deprecated Use TableOfContentsGenerator::NORMALIZE_FLAT instead
*/
public const NORMALIZE_FLAT = TableOfContentsGenerator::NORMALIZE_FLAT;
public const POSITION_TOP = 'top';
public const POSITION_TOP = 'top';
public const POSITION_BEFORE_HEADINGS = 'before-headings';
public const POSITION_PLACEHOLDER = 'placeholder';
public const POSITION_PLACEHOLDER = 'placeholder';
/** @var ConfigurationInterface */
private $config;
/** @psalm-readonly-allow-private-mutation */
private ConfigurationInterface $config;
public function onDocumentParsed(DocumentParsedEvent $event): void
{
$document = $event->getDocument();
$generator = new TableOfContentsGenerator(
$this->config->get('table_of_contents/style', TableOfContentsGenerator::STYLE_BULLET),
$this->config->get('table_of_contents/normalize', TableOfContentsGenerator::NORMALIZE_RELATIVE),
(int) $this->config->get('table_of_contents/min_heading_level', 1),
(int) $this->config->get('table_of_contents/max_heading_level', 6)
(string) $this->config->get('table_of_contents/style'),
(string) $this->config->get('table_of_contents/normalize'),
(int) $this->config->get('table_of_contents/min_heading_level'),
(int) $this->config->get('table_of_contents/max_heading_level'),
(string) $this->config->get('heading_permalink/fragment_prefix'),
);
$toc = $generator->generate($document);
@@ -73,13 +52,13 @@ final class TableOfContentsBuilder implements ConfigurationAwareInterface
}
// Add custom CSS class(es), if defined
$class = $this->config->get('table_of_contents/html_class', 'table-of-contents');
if (!empty($class)) {
$toc->data['attributes']['class'] = $class;
$class = $this->config->get('table_of_contents/html_class');
if ($class !== null) {
$toc->data->append('attributes/class', $class);
}
// Add the TOC to the Document
$position = $this->config->get('table_of_contents/position', self::POSITION_TOP);
$position = $this->config->get('table_of_contents/position');
if ($position === self::POSITION_TOP) {
$document->prependChild($toc);
} elseif ($position === self::POSITION_BEFORE_HEADINGS) {
@@ -87,41 +66,41 @@ final class TableOfContentsBuilder implements ConfigurationAwareInterface
} elseif ($position === self::POSITION_PLACEHOLDER) {
$this->replacePlaceholders($document, $toc);
} else {
throw new InvalidOptionException(\sprintf('Invalid config option "%s" for "table_of_contents/position"', $position));
throw InvalidConfigurationException::forConfigOption('table_of_contents/position', $position);
}
}
private function insertBeforeFirstLinkedHeading(Document $document, TableOfContents $toc): void
{
$walker = $document->walker();
while ($event = $walker->next()) {
if ($event->isEntering() && ($node = $event->getNode()) instanceof HeadingPermalink && ($parent = $node->parent()) instanceof Heading) {
$parent->insertBefore($toc);
foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
if (! $node instanceof Heading) {
continue;
}
return;
foreach ($node->children() as $child) {
if ($child instanceof HeadingPermalink) {
$node->insertBefore($toc);
return;
}
}
}
}
private function replacePlaceholders(Document $document, TableOfContents $toc): void
{
$walker = $document->walker();
while ($event = $walker->next()) {
// Add the block once we find a placeholder (and we're about to leave it)
if (!$event->getNode() instanceof TableOfContentsPlaceholder) {
foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
// Add the block once we find a placeholder
if (! $node instanceof TableOfContentsPlaceholder) {
continue;
}
if ($event->isEntering()) {
continue;
}
$event->getNode()->replaceWith(clone $toc);
$node->replaceWith(clone $toc);
}
}
public function setConfiguration(ConfigurationInterface $config)
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $config;
$this->config = $configuration;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,21 +13,41 @@
namespace League\CommonMark\Extension\TableOfContents;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\ExtensionInterface;
use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
use League\CommonMark\Extension\CommonMark\Renderer\Block\ListBlockRenderer;
use League\CommonMark\Extension\ConfigurableExtensionInterface;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder;
use League\Config\ConfigurationBuilderInterface;
use Nette\Schema\Expect;
final class TableOfContentsExtension implements ExtensionInterface
final class TableOfContentsExtension implements ConfigurableExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment): void
public function configureSchema(ConfigurationBuilderInterface $builder): void
{
$builder->addSchema('table_of_contents', Expect::structure([
'position' => Expect::anyOf(TableOfContentsBuilder::POSITION_BEFORE_HEADINGS, TableOfContentsBuilder::POSITION_PLACEHOLDER, TableOfContentsBuilder::POSITION_TOP)->default(TableOfContentsBuilder::POSITION_TOP),
'style' => Expect::anyOf(ListBlock::TYPE_BULLET, ListBlock::TYPE_ORDERED)->default(ListBlock::TYPE_BULLET),
'normalize' => Expect::anyOf(TableOfContentsGenerator::NORMALIZE_RELATIVE, TableOfContentsGenerator::NORMALIZE_FLAT, TableOfContentsGenerator::NORMALIZE_DISABLED)->default(TableOfContentsGenerator::NORMALIZE_RELATIVE),
'min_heading_level' => Expect::int()->min(1)->max(6)->default(1),
'max_heading_level' => Expect::int()->min(1)->max(6)->default(6),
'html_class' => Expect::string()->default('table-of-contents'),
'placeholder' => Expect::anyOf(Expect::string(), Expect::null())->default(null),
]));
}
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addRenderer(TableOfContents::class, new TableOfContentsRenderer(new ListBlockRenderer()));
$environment->addEventListener(DocumentParsedEvent::class, [new TableOfContentsBuilder(), 'onDocumentParsed'], -150);
if ($environment->getConfig('table_of_contents/position') === TableOfContentsBuilder::POSITION_PLACEHOLDER) {
$environment->addBlockParser(new TableOfContentsPlaceholderParser(), 200);
// phpcs:ignore SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed
if ($environment->getConfiguration()->get('table_of_contents/position') === TableOfContentsBuilder::POSITION_PLACEHOLDER) {
$environment->addBlockStartParser(TableOfContentsPlaceholderParser::blockStartParser(), 200);
// If a placeholder cannot be replaced with a TOC element this renderer will ensure the parser won't error out
$environment->addBlockRenderer(TableOfContentsPlaceholder::class, new TableOfContentsPlaceholderRenderer());
$environment->addRenderer(TableOfContentsPlaceholder::class, new TableOfContentsPlaceholderRenderer());
}
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,46 +13,58 @@
namespace League\CommonMark\Extension\TableOfContents;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Block\Element\Heading;
use League\CommonMark\Block\Element\ListBlock;
use League\CommonMark\Block\Element\ListData;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\Exception\InvalidOptionException;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
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\Extension\CommonMark\Node\Inline\Link;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalink;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
use League\CommonMark\Extension\TableOfContents\Normalizer\AsIsNormalizerStrategy;
use League\CommonMark\Extension\TableOfContents\Normalizer\FlatNormalizerStrategy;
use League\CommonMark\Extension\TableOfContents\Normalizer\NormalizerStrategyInterface;
use League\CommonMark\Extension\TableOfContents\Normalizer\RelativeNormalizerStrategy;
use League\CommonMark\Inline\Element\AbstractStringContainer;
use League\CommonMark\Inline\Element\Link;
use League\CommonMark\Node\Block\Document;
use League\CommonMark\Node\NodeIterator;
use League\CommonMark\Node\RawMarkupContainerInterface;
use League\CommonMark\Node\StringContainerHelper;
use League\Config\Exception\InvalidConfigurationException;
final class TableOfContentsGenerator implements TableOfContentsGeneratorInterface
{
public const STYLE_BULLET = ListBlock::TYPE_BULLET;
public const STYLE_BULLET = ListBlock::TYPE_BULLET;
public const STYLE_ORDERED = ListBlock::TYPE_ORDERED;
public const NORMALIZE_DISABLED = 'as-is';
public const NORMALIZE_RELATIVE = 'relative';
public const NORMALIZE_FLAT = 'flat';
public const NORMALIZE_FLAT = 'flat';
/** @var string */
private $style;
/** @var string */
private $normalizationStrategy;
/** @var int */
private $minHeadingLevel;
/** @var int */
private $maxHeadingLevel;
/** @psalm-readonly */
private string $style;
public function __construct(string $style, string $normalizationStrategy, int $minHeadingLevel, int $maxHeadingLevel)
/** @psalm-readonly */
private string $normalizationStrategy;
/** @psalm-readonly */
private int $minHeadingLevel;
/** @psalm-readonly */
private int $maxHeadingLevel;
/** @psalm-readonly */
private string $fragmentPrefix;
public function __construct(string $style, string $normalizationStrategy, int $minHeadingLevel, int $maxHeadingLevel, string $fragmentPrefix)
{
$this->style = $style;
$this->style = $style;
$this->normalizationStrategy = $normalizationStrategy;
$this->minHeadingLevel = $minHeadingLevel;
$this->maxHeadingLevel = $maxHeadingLevel;
$this->minHeadingLevel = $minHeadingLevel;
$this->maxHeadingLevel = $maxHeadingLevel;
$this->fragmentPrefix = $fragmentPrefix;
if ($fragmentPrefix !== '') {
$this->fragmentPrefix .= '-';
}
}
public function generate(Document $document): ?TableOfContents
@@ -64,7 +78,7 @@ final class TableOfContentsGenerator implements TableOfContentsGeneratorInterfac
foreach ($this->getHeadingLinks($document) as $headingLink) {
$heading = $headingLink->parent();
// Make sure this is actually tied to a heading
if (!$heading instanceof Heading) {
if (! $heading instanceof Heading) {
continue;
}
@@ -74,30 +88,26 @@ final class TableOfContentsGenerator implements TableOfContentsGeneratorInterfac
}
// Keep track of the first heading we see - we might need this later
$firstHeading = $firstHeading ?? $heading;
$firstHeading ??= $heading;
// Keep track of the start and end lines
$toc->setStartLine($firstHeading->getStartLine());
$toc->setEndLine($heading->getEndLine());
// Create the new link
$link = new Link('#' . $headingLink->getSlug(), self::getHeadingText($heading));
$paragraph = new Paragraph();
$paragraph->setStartLine($heading->getStartLine());
$paragraph->setEndLine($heading->getEndLine());
$paragraph->appendChild($link);
$link = new Link('#' . $this->fragmentPrefix . $headingLink->getSlug(), StringContainerHelper::getChildText($heading, [RawMarkupContainerInterface::class]));
$listItem = new ListItem($toc->getListData());
$listItem->setStartLine($heading->getStartLine());
$listItem->setEndLine($heading->getEndLine());
$listItem->appendChild($paragraph);
$listItem->appendChild($link);
// Add it to the correct place
$normalizer->addItem($heading->getLevel(), $listItem);
}
// Don't add the TOC if no headings were present
if (!$toc->hasChildren() || $firstHeading === null) {
if (! $toc->hasChildren() || $firstHeading === null) {
return null;
}
@@ -113,7 +123,7 @@ final class TableOfContentsGenerator implements TableOfContentsGeneratorInterfac
} elseif ($this->style === self::STYLE_ORDERED) {
$listData->type = ListBlock::TYPE_ORDERED;
} else {
throw new InvalidOptionException(\sprintf('Invalid table of contents list style "%s"', $this->style));
throw new InvalidConfigurationException(\sprintf('Invalid table of contents list style: "%s"', $this->style));
}
$toc = new TableOfContents($listData);
@@ -125,16 +135,19 @@ final class TableOfContentsGenerator implements TableOfContentsGeneratorInterfac
}
/**
* @param Document $document
*
* @return iterable<HeadingPermalink>
*/
private function getHeadingLinks(Document $document)
private function getHeadingLinks(Document $document): iterable
{
$walker = $document->walker();
while ($event = $walker->next()) {
if ($event->isEntering() && ($node = $event->getNode()) instanceof HeadingPermalink) {
yield $node;
foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) {
if (! $node instanceof Heading) {
continue;
}
foreach ($node->children() as $child) {
if ($child instanceof HeadingPermalink) {
yield $child;
}
}
}
}
@@ -149,24 +162,7 @@ final class TableOfContentsGenerator implements TableOfContentsGeneratorInterfac
case self::NORMALIZE_FLAT:
return new FlatNormalizerStrategy($toc);
default:
throw new InvalidOptionException(\sprintf('Invalid table of contents normalization strategy "%s"', $this->normalizationStrategy));
throw new InvalidConfigurationException(\sprintf('Invalid table of contents normalization strategy: "%s"', $this->normalizationStrategy));
}
}
/**
* @return string
*/
private static function getHeadingText(Heading $heading)
{
$text = '';
$walker = $heading->walker();
while ($event = $walker->next()) {
if ($event->isEntering() && ($child = $event->getNode()) instanceof AbstractStringContainer) {
$text .= $child->getContent();
}
}
return $text;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,13 +13,10 @@
namespace League\CommonMark\Extension\TableOfContents;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContents;
use League\CommonMark\Node\Block\Document;
interface TableOfContentsGeneratorInterface
{
public function generate(Document $document): ?TableOfContents;
}
// Trigger autoload without causing a deprecated error
\class_exists(TableOfContents::class);

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,37 +13,62 @@
namespace League\CommonMark\Extension\TableOfContents;
use League\CommonMark\Block\Parser\BlockParserInterface;
use League\CommonMark\ContextInterface;
use League\CommonMark\Cursor;
use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder;
use League\CommonMark\Util\ConfigurationAwareInterface;
use League\CommonMark\Util\ConfigurationInterface;
use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
use League\CommonMark\Parser\Block\BlockContinue;
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
use League\CommonMark\Parser\Block\BlockStart;
use League\CommonMark\Parser\Block\BlockStartParserInterface;
use League\CommonMark\Parser\Cursor;
use League\CommonMark\Parser\MarkdownParserStateInterface;
use League\Config\ConfigurationAwareInterface;
use League\Config\ConfigurationInterface;
final class TableOfContentsPlaceholderParser implements BlockParserInterface, ConfigurationAwareInterface
final class TableOfContentsPlaceholderParser extends AbstractBlockContinueParser
{
/** @var ConfigurationInterface */
private $config;
/** @psalm-readonly */
private TableOfContentsPlaceholder $block;
public function parse(ContextInterface $context, Cursor $cursor): bool
public function __construct()
{
$placeholder = $this->config->get('table_of_contents/placeholder');
if ($placeholder === null) {
return false;
}
// The placeholder must be the only thing on the line
if ($cursor->match('/^' . \preg_quote($placeholder, '/') . '$/') === null) {
return false;
}
$context->addBlock(new TableOfContentsPlaceholder());
return true;
$this->block = new TableOfContentsPlaceholder();
}
public function setConfiguration(ConfigurationInterface $configuration)
public function getBlock(): TableOfContentsPlaceholder
{
$this->config = $configuration;
return $this->block;
}
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
{
return BlockContinue::none();
}
public static function blockStartParser(): BlockStartParserInterface
{
return new class () implements BlockStartParserInterface, ConfigurationAwareInterface {
/** @psalm-readonly-allow-private-mutation */
private ConfigurationInterface $config;
public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserState): ?BlockStart
{
$placeholder = $this->config->get('table_of_contents/placeholder');
if ($placeholder === null) {
return BlockStart::none();
}
// The placeholder must be the only thing on the line
if ($cursor->match('/^' . \preg_quote($placeholder, '/') . '$/') === null) {
return BlockStart::none();
}
return BlockStart::of(new TableOfContentsPlaceholderParser())->at($cursor);
}
public function setConfiguration(ConfigurationInterface $configuration): void
{
$this->config = $configuration;
}
};
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,14 +13,28 @@
namespace League\CommonMark\Extension\TableOfContents;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Block\Renderer\BlockRendererInterface;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Xml\XmlNodeRendererInterface;
final class TableOfContentsPlaceholderRenderer implements BlockRendererInterface
final class TableOfContentsPlaceholderRenderer implements NodeRendererInterface, XmlNodeRendererInterface
{
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
return '<!-- table of contents -->';
}
public function getXmlTagName(Node $node): string
{
return 'table_of_contents_placeholder';
}
/**
* @return array<string, scalar>
*/
public function getXmlAttributes(Node $node): array
{
return [];
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,14 +13,14 @@
namespace League\CommonMark\Extension\TaskList;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
final class TaskListExtension implements ExtensionInterface
{
public function register(ConfigurableEnvironmentInterface $environment)
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addInlineParser(new TaskListItemMarkerParser(), 35);
$environment->addInlineRenderer(TaskListItemMarker::class, new TaskListItemMarkerRenderer());
$environment->addRenderer(TaskListItemMarker::class, new TaskListItemMarkerRenderer());
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,15 +13,17 @@
namespace League\CommonMark\Extension\TaskList;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Node\Inline\AbstractInline;
final class TaskListItemMarker extends AbstractInline
{
/** @var bool */
protected $checked = false;
/** @psalm-readonly-allow-private-mutation */
private bool $checked;
public function __construct(bool $isCompleted)
{
parent::__construct();
$this->checked = $isCompleted;
}
@@ -28,10 +32,8 @@ final class TaskListItemMarker extends AbstractInline
return $this->checked;
}
public function setChecked(bool $checked): self
public function setChecked(bool $checked): void
{
$this->checked = $checked;
return $this;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,16 +13,17 @@
namespace League\CommonMark\Extension\TaskList;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\Inline\Parser\InlineParserInterface;
use League\CommonMark\InlineParserContext;
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
final class TaskListItemMarkerParser implements InlineParserInterface
{
public function getCharacters(): array
public function getMatchDefinition(): InlineParserMatch
{
return ['['];
return InlineParserMatch::oneOf('[ ]', '[x]');
}
public function parse(InlineParserContext $inlineContext): bool
@@ -28,17 +31,14 @@ final class TaskListItemMarkerParser implements InlineParserInterface
$container = $inlineContext->getContainer();
// Checkbox must come at the beginning of the first paragraph of the list item
if ($container->hasChildren() || !($container instanceof Paragraph && $container->parent() && $container->parent() instanceof ListItem)) {
if ($container->hasChildren() || ! ($container instanceof Paragraph && $container->parent() && $container->parent() instanceof ListItem)) {
return false;
}
$cursor = $inlineContext->getCursor();
$cursor = $inlineContext->getCursor();
$oldState = $cursor->saveState();
$m = $cursor->match('/\[[ xX]\]/');
if ($m === null) {
return false;
}
$cursor->advanceBy(3);
if ($cursor->getNextNonSpaceCharacter() === null) {
$cursor->restoreState($oldState);
@@ -46,7 +46,7 @@ final class TaskListItemMarkerParser implements InlineParserInterface
return false;
}
$isChecked = $m !== '[ ]';
$isChecked = $inlineContext->getFullMatch() !== '[ ]';
$container->appendChild(new TaskListItemMarker($isChecked));

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/*
* This file is part of the league/commonmark package.
*
@@ -11,28 +13,29 @@
namespace League\CommonMark\Extension\TaskList;
use League\CommonMark\ElementRendererInterface;
use League\CommonMark\HtmlElement;
use League\CommonMark\Inline\Element\AbstractInline;
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
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 TaskListItemMarkerRenderer implements InlineRendererInterface
final class TaskListItemMarkerRenderer implements NodeRendererInterface, XmlNodeRendererInterface
{
/**
* @param TaskListItemMarker $inline
* @param ElementRendererInterface $htmlRenderer
* @param TaskListItemMarker $node
*
* @return HtmlElement|string|null
* {@inheritDoc}
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable
{
if (!($inline instanceof TaskListItemMarker)) {
throw new \InvalidArgumentException('Incompatible inline type: ' . \get_class($inline));
}
TaskListItemMarker::assertInstanceOf($node);
$checkbox = new HtmlElement('input', [], '', true);
$attrs = $node->data->get('attributes');
$checkbox = new HtmlElement('input', $attrs, '', true);
if ($inline->isChecked()) {
if ($node->isChecked()) {
$checkbox->setAttribute('checked', '');
}
@@ -41,4 +44,27 @@ final class TaskListItemMarkerRenderer implements InlineRendererInterface
return $checkbox;
}
public function getXmlTagName(Node $node): string
{
return 'task_list_item_marker';
}
/**
* @param TaskListItemMarker $node
*
* @return array<string, scalar>
*
* @psalm-suppress MoreSpecificImplementedParamType
*/
public function getXmlAttributes(Node $node): array
{
TaskListItemMarker::assertInstanceOf($node);
if ($node->isChecked()) {
return ['checked' => 'checked'];
}
return [];
}
}