diff --git a/src/Extension/TableOfContents/Node/TableOfContentsWrapper.php b/src/Extension/TableOfContents/Node/TableOfContentsWrapper.php new file mode 100644 index 0000000000..4716273715 --- /dev/null +++ b/src/Extension/TableOfContents/Node/TableOfContentsWrapper.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace League\CommonMark\Extension\TableOfContents\Node; + +use League\CommonMark\Exception\InvalidArgumentException; +use League\CommonMark\Node\Block\AbstractBlock; + +final class TableOfContentsWrapper extends AbstractBlock +{ + public function getInnerToc(): TableOfContents + { + $children = $this->children(); + if (! \is_array($children)) { + /** @psalm-suppress NoValue */ + $children = \iterator_to_array($children); + } + + if (\count($children) !== 2) { + throw new InvalidArgumentException( + 'TableOfContentsWrapper nodes should have 2 children, found ' . \count($children) + ); + } + + $inner = $children[1]; + if (! $inner instanceof TableOfContents) { + throw new InvalidArgumentException( + 'TableOfContentsWrapper second node should be a TableOfContents, found ' . \get_class($inner) + ); + } + + return $inner; + } +} diff --git a/src/Extension/TableOfContents/TableOfContentsBuilder.php b/src/Extension/TableOfContents/TableOfContentsBuilder.php index 7fe2b09928..87d19b1341 100644 --- a/src/Extension/TableOfContents/TableOfContentsBuilder.php +++ b/src/Extension/TableOfContents/TableOfContentsBuilder.php @@ -14,10 +14,13 @@ namespace League\CommonMark\Extension\TableOfContents; use League\CommonMark\Event\DocumentParsedEvent; +use League\CommonMark\Exception\InvalidArgumentException; 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\Extension\TableOfContents\Node\TableOfContentsWrapper; +use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Node\Block\Document; use League\CommonMark\Node\NodeIterator; use League\Config\ConfigurationAwareInterface; @@ -43,6 +46,7 @@ public function onDocumentParsed(DocumentParsedEvent $event): void (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'), + (string) $this->config->get('table_of_contents/label'), ); $toc = $generator->generate($document); @@ -54,7 +58,11 @@ public function onDocumentParsed(DocumentParsedEvent $event): void // Add custom CSS class(es), if defined $class = $this->config->get('table_of_contents/html_class'); if ($class !== null) { - $toc->data->append('attributes/class', $class); + if ($toc instanceof TableOfContentsWrapper) { + $toc->getInnerToc()->data->append('attributes/class', $class); + } else { + $toc->data->append('attributes/class', $class); + } } // Add the TOC to the Document @@ -70,8 +78,20 @@ public function onDocumentParsed(DocumentParsedEvent $event): void } } - private function insertBeforeFirstLinkedHeading(Document $document, TableOfContents $toc): void + /** + * @psalm-param TableOfContents|TableOfContentsWrapper $toc + * + * @phpstan-param TableOfContents|TableOfContentsWrapper $toc + */ + private function insertBeforeFirstLinkedHeading(Document $document, AbstractBlock $toc): void { + // @phpstan-ignore booleanAnd.alwaysFalse + if (! $toc instanceof TableOfContents && ! $toc instanceof TableOfContentsWrapper) { + throw new InvalidArgumentException( + 'Toc should be a TableOfContents or TableOfContentsWrapper, got ' . \get_class($toc) + ); + } + foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { if (! $node instanceof Heading) { continue; @@ -87,8 +107,20 @@ private function insertBeforeFirstLinkedHeading(Document $document, TableOfConte } } - private function replacePlaceholders(Document $document, TableOfContents $toc): void + /** + * @psalm-param TableOfContents|TableOfContentsWrapper $toc + * + * @phpstan-param TableOfContents|TableOfContentsWrapper $toc + */ + private function replacePlaceholders(Document $document, AbstractBlock $toc): void { + // @phpstan-ignore booleanAnd.alwaysFalse + if (! $toc instanceof TableOfContents && ! $toc instanceof TableOfContentsWrapper) { + throw new InvalidArgumentException( + 'Toc should be a TableOfContents or TableOfContentsWrapper, got ' . \get_class($toc) + ); + } + foreach ($document->iterator(NodeIterator::FLAG_BLOCKS_ONLY) as $node) { // Add the block once we find a placeholder if (! $node instanceof TableOfContentsPlaceholder) { diff --git a/src/Extension/TableOfContents/TableOfContentsExtension.php b/src/Extension/TableOfContents/TableOfContentsExtension.php index 9c8223bedd..444f875434 100644 --- a/src/Extension/TableOfContents/TableOfContentsExtension.php +++ b/src/Extension/TableOfContents/TableOfContentsExtension.php @@ -20,6 +20,7 @@ use League\CommonMark\Extension\ConfigurableExtensionInterface; use League\CommonMark\Extension\TableOfContents\Node\TableOfContents; use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsPlaceholder; +use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsWrapper; use League\Config\ConfigurationBuilderInterface; use Nette\Schema\Expect; @@ -35,12 +36,14 @@ public function configureSchema(ConfigurationBuilderInterface $builder): void '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), + 'label' => Expect::anyOf(Expect::string(), Expect::null())->default(null), ])); } public function register(EnvironmentBuilderInterface $environment): void { $environment->addRenderer(TableOfContents::class, new TableOfContentsRenderer(new ListBlockRenderer())); + $environment->addRenderer(TableOfContentsWrapper::class, new TableOfContentsWrapperRenderer()); $environment->addEventListener(DocumentParsedEvent::class, [new TableOfContentsBuilder(), 'onDocumentParsed'], -150); // phpcs:ignore SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed diff --git a/src/Extension/TableOfContents/TableOfContentsGenerator.php b/src/Extension/TableOfContents/TableOfContentsGenerator.php index f0df96bfdc..1bb50f9465 100644 --- a/src/Extension/TableOfContents/TableOfContentsGenerator.php +++ b/src/Extension/TableOfContents/TableOfContentsGenerator.php @@ -18,13 +18,17 @@ 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\CommonMark\Node\Inline\Strong; use League\CommonMark\Extension\HeadingPermalink\HeadingPermalink; use League\CommonMark\Extension\TableOfContents\Node\TableOfContents; +use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsWrapper; 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\Node\Block\AbstractBlock; use League\CommonMark\Node\Block\Document; +use League\CommonMark\Node\Inline\Text; use League\CommonMark\Node\NodeIterator; use League\CommonMark\Node\RawMarkupContainerInterface; use League\CommonMark\Node\StringContainerHelper; @@ -54,20 +58,30 @@ final class TableOfContentsGenerator implements TableOfContentsGeneratorInterfac /** @psalm-readonly */ private string $fragmentPrefix; - public function __construct(string $style, string $normalizationStrategy, int $minHeadingLevel, int $maxHeadingLevel, string $fragmentPrefix) + /** @psalm-readonly */ + private string $label; + + public function __construct(string $style, string $normalizationStrategy, int $minHeadingLevel, int $maxHeadingLevel, string $fragmentPrefix, string $label = '') { $this->style = $style; $this->normalizationStrategy = $normalizationStrategy; $this->minHeadingLevel = $minHeadingLevel; $this->maxHeadingLevel = $maxHeadingLevel; $this->fragmentPrefix = $fragmentPrefix; + $this->label = $label; if ($fragmentPrefix !== '') { $this->fragmentPrefix .= '-'; } } - public function generate(Document $document): ?TableOfContents + /** + * If there is a table of contents, returns either a `TableOfContents` or + * `TableOfContentsWrapper` node object + * + * @psalm-return TableOfContents|TableOfContentsWrapper + */ + public function generate(Document $document): ?AbstractBlock { $toc = $this->createToc($document); @@ -111,6 +125,18 @@ public function generate(Document $document): ?TableOfContents return null; } + if ($this->label !== '') { + $label = new Strong(); + $label->appendChild(new Text($this->label)); + $wrapper = new TableOfContentsWrapper(); + $wrapper->appendChild($label); + $wrapper->appendChild($toc); + $wrapper->setStartLine($toc->getStartLine()); + $wrapper->setEndLine($toc->getEndLine()); + + return $wrapper; + } + return $toc; } diff --git a/src/Extension/TableOfContents/TableOfContentsGeneratorInterface.php b/src/Extension/TableOfContents/TableOfContentsGeneratorInterface.php index 64ecb8e504..9f1f388062 100644 --- a/src/Extension/TableOfContents/TableOfContentsGeneratorInterface.php +++ b/src/Extension/TableOfContents/TableOfContentsGeneratorInterface.php @@ -14,9 +14,17 @@ namespace League\CommonMark\Extension\TableOfContents; use League\CommonMark\Extension\TableOfContents\Node\TableOfContents; +use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsWrapper; +use League\CommonMark\Node\Block\AbstractBlock; use League\CommonMark\Node\Block\Document; interface TableOfContentsGeneratorInterface { - public function generate(Document $document): ?TableOfContents; + /** + * If there is a table of contents, returns either a `TableOfContents` or + * `TableOfContentsWrapper` node object. + * + * @psalm-return TableOfContents|TableOfContentsWrapper + */ + public function generate(Document $document): ?AbstractBlock; } diff --git a/src/Extension/TableOfContents/TableOfContentsWrapperRenderer.php b/src/Extension/TableOfContents/TableOfContentsWrapperRenderer.php new file mode 100644 index 0000000000..56a1b68bb9 --- /dev/null +++ b/src/Extension/TableOfContents/TableOfContentsWrapperRenderer.php @@ -0,0 +1,65 @@ + + * + * 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\Exception\InvalidArgumentException; +use League\CommonMark\Extension\TableOfContents\Node\TableOfContentsWrapper; +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 TableOfContentsWrapperRenderer implements NodeRendererInterface, XmlNodeRendererInterface +{ + /** + * {@inheritDoc} + */ + public function render(Node $node, ChildNodeRendererInterface $childRenderer) + { + TableOfContentsWrapper::assertInstanceOf($node); + $children = $node->children(); + if (! \is_array($children)) { + /** @psalm-suppress NoValue */ + $children = \iterator_to_array($children); + } + + if (\count($children) !== 2) { + throw new InvalidArgumentException( + 'TableOfContentsWrapper nodes should have 2 children, found ' . \count($children) + ); + } + + $attrs = $node->data->get('attributes'); + + return new HtmlElement( + 'div', + $attrs, + $childRenderer->renderNodes($children) + ); + } + + public function getXmlTagName(Node $node): string + { + return 'table_of_contents_wrapper'; + } + + /** + * @return array + */ + public function getXmlAttributes(Node $node): array + { + return []; + } +} diff --git a/tests/functional/Extension/TableOfContents/md/has-label.html b/tests/functional/Extension/TableOfContents/md/has-label.html new file mode 100644 index 0000000000..dc3797ba32 --- /dev/null +++ b/tests/functional/Extension/TableOfContents/md/has-label.html @@ -0,0 +1,11 @@ +
Table of Contents +
+

This is my document.

+

Hello World!

+

Isn't Markdown Great?

diff --git a/tests/functional/Extension/TableOfContents/md/has-label.md b/tests/functional/Extension/TableOfContents/md/has-label.md new file mode 100644 index 0000000000..896d2e8492 --- /dev/null +++ b/tests/functional/Extension/TableOfContents/md/has-label.md @@ -0,0 +1,10 @@ +--- +table_of_contents: + label: Table of Contents +--- + +This is my document. + +# Hello World! + +## Isn't Markdown Great? diff --git a/tests/functional/Extension/TableOfContents/xml/has-label.md b/tests/functional/Extension/TableOfContents/xml/has-label.md new file mode 100644 index 0000000000..896d2e8492 --- /dev/null +++ b/tests/functional/Extension/TableOfContents/xml/has-label.md @@ -0,0 +1,10 @@ +--- +table_of_contents: + label: Table of Contents +--- + +This is my document. + +# Hello World! + +## Isn't Markdown Great? diff --git a/tests/functional/Extension/TableOfContents/xml/has-label.xml b/tests/functional/Extension/TableOfContents/xml/has-label.xml new file mode 100644 index 0000000000..e597d01dd9 --- /dev/null +++ b/tests/functional/Extension/TableOfContents/xml/has-label.xml @@ -0,0 +1,33 @@ + + + + + Table of Contents + + + + + Hello World! + + + + + Isn't Markdown Great? + + + + + + + + This is my document. + + + + Hello World! + + + + Isn't Markdown Great? + +